Compare commits
1 Commits
feat/plan-
...
fix/audit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9147a06c00 |
@@ -1,145 +0,0 @@
|
|||||||
// Tenant self-service billing helpers — wraps /api/v1/billing/* on arcadia-core.
|
|
||||||
//
|
|
||||||
// The provider is resolved server-side (mock in dev, a real PSP in prod), so
|
|
||||||
// these helpers are provider-agnostic: `createCheckoutSession` returns a hosted
|
|
||||||
// URL to redirect the browser to, whatever the backend provider is.
|
|
||||||
// Shapes mirror `ArcadiaWeb.API.BillingJSON`.
|
|
||||||
|
|
||||||
import type { ArcadiaClient } from "@crema/arcadia-client"
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Wire types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface BillingOverview {
|
|
||||||
billing_model: string | null
|
|
||||||
plan: string
|
|
||||||
subscription_status: string
|
|
||||||
current_period_start: string | null
|
|
||||||
current_period_end: string | null
|
|
||||||
trial_ends_at: string | null
|
|
||||||
billing_email: string | null
|
|
||||||
plan_limits: Record<string, unknown>
|
|
||||||
tenant_limits: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanPricing {
|
|
||||||
period: string
|
|
||||||
price_cents: number
|
|
||||||
currency: string
|
|
||||||
discount_label: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanMeter {
|
|
||||||
meter_key: string
|
|
||||||
included_units: number | null
|
|
||||||
overage_price_cents: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CatalogPlan {
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
billing_track: string
|
|
||||||
trial_days: number
|
|
||||||
metadata: Record<string, unknown>
|
|
||||||
pricing: PlanPricing[]
|
|
||||||
meters: PlanMeter[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Invoice {
|
|
||||||
id: string | null
|
|
||||||
amount: number | null
|
|
||||||
currency: string | null
|
|
||||||
status: string | null
|
|
||||||
due_date: string | null
|
|
||||||
paid_at: string | null
|
|
||||||
invoice_url: string | null
|
|
||||||
pdf_url: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutSession {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortalSession {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Reads
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function getBillingOverview(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
): Promise<BillingOverview> {
|
|
||||||
const res = await arcadia.GET<{ data: BillingOverview }>(
|
|
||||||
"/api/v1/billing/overview",
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listBillingPlans(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
): Promise<CatalogPlan[]> {
|
|
||||||
const res = await arcadia.GET<{ data: CatalogPlan[] }>(
|
|
||||||
"/api/v1/billing/plans",
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listInvoices(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
opts: { limit?: number } = {},
|
|
||||||
): Promise<Invoice[]> {
|
|
||||||
const qs = opts.limit ? `?limit=${opts.limit}` : ""
|
|
||||||
const res = await arcadia.GET<{ data: Invoice[] }>(
|
|
||||||
`/api/v1/billing/invoices${qs}`,
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Actions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a hosted checkout session for `plan` and return the URL to redirect
|
|
||||||
* the browser to. `successUrl`/`cancelUrl` are where the provider returns the
|
|
||||||
* user (default: back to this page).
|
|
||||||
*/
|
|
||||||
export async function createCheckoutSession(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
input: { plan: string; successUrl?: string; cancelUrl?: string },
|
|
||||||
): Promise<CheckoutSession> {
|
|
||||||
const res = await arcadia.POST<{ data: CheckoutSession }>(
|
|
||||||
"/api/v1/billing/checkout",
|
|
||||||
{
|
|
||||||
body: {
|
|
||||||
plan: input.plan,
|
|
||||||
success_url: input.successUrl,
|
|
||||||
cancel_url: input.cancelUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a billing-portal session for self-service payment management. */
|
|
||||||
export async function createPortalSession(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
input: { returnUrl?: string } = {},
|
|
||||||
): Promise<PortalSession> {
|
|
||||||
const res = await arcadia.POST<{ data: PortalSession }>(
|
|
||||||
"/api/v1/billing/portal",
|
|
||||||
{ body: { return_url: input.returnUrl } },
|
|
||||||
)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel the active subscription (at period end). */
|
|
||||||
export async function cancelSubscription(
|
|
||||||
arcadia: ArcadiaClient,
|
|
||||||
): Promise<void> {
|
|
||||||
await arcadia.POST("/api/v1/billing/cancel", { body: {} })
|
|
||||||
}
|
|
||||||
@@ -1,210 +1,18 @@
|
|||||||
// Tenant subscription + billing — self-service surface.
|
// Tenant subscription + billing — placeholder. Real surface lists the
|
||||||
//
|
// active plan, renewal date, invoices, and payment method for the
|
||||||
// Wired to /api/v1/billing/* on arcadia-core. The payment provider is resolved
|
// active tenant. Data source not wired yet.
|
||||||
// server-side (mock in dev), so "Subscribe" creates a hosted checkout session
|
|
||||||
// and redirects the browser to the returned URL; on return the overview
|
|
||||||
// reflects the new plan.
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { CreditCard } from "lucide-react"
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Check,
|
|
||||||
CreditCard,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"
|
|
||||||
import { Badge } from "~/components/ui/badge"
|
|
||||||
import { Button } from "~/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card"
|
} from "~/components/ui/card"
|
||||||
import { Separator } from "~/components/ui/separator"
|
|
||||||
import { Skeleton } from "~/components/ui/skeleton"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "~/components/ui/table"
|
|
||||||
import {
|
|
||||||
type BillingOverview,
|
|
||||||
type CatalogPlan,
|
|
||||||
type Invoice,
|
|
||||||
type PlanPricing,
|
|
||||||
cancelSubscription,
|
|
||||||
createCheckoutSession,
|
|
||||||
createPortalSession,
|
|
||||||
getBillingOverview,
|
|
||||||
listBillingPlans,
|
|
||||||
listInvoices,
|
|
||||||
} from "~/lib/arcadia/billing"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Formatting helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function monthlyPricing(plan: CatalogPlan): PlanPricing | null {
|
|
||||||
if (!plan.pricing.length) return null
|
|
||||||
return plan.pricing.find((p) => p.period === "monthly") ?? plan.pricing[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMoney(cents: number | null, currency: string | null): string {
|
|
||||||
if (cents == null) return "—"
|
|
||||||
const cur = currency || "AUD"
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(undefined, {
|
|
||||||
style: "currency",
|
|
||||||
currency: cur,
|
|
||||||
}).format(cents / 100)
|
|
||||||
} catch {
|
|
||||||
return `${(cents / 100).toFixed(2)} ${cur}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
|
||||||
if (!iso) return "—"
|
|
||||||
const d = new Date(iso)
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusVariant(
|
|
||||||
status: string,
|
|
||||||
): "default" | "secondary" | "destructive" | "outline" {
|
|
||||||
switch (status) {
|
|
||||||
case "active":
|
|
||||||
return "default"
|
|
||||||
case "trialing":
|
|
||||||
return "secondary"
|
|
||||||
case "past_due":
|
|
||||||
case "unpaid":
|
|
||||||
case "canceled":
|
|
||||||
case "cancelled":
|
|
||||||
return "destructive"
|
|
||||||
default:
|
|
||||||
return "outline"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Route
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function PlanRoute() {
|
export default function PlanRoute() {
|
||||||
const arcadia = useArcadiaClient()
|
|
||||||
|
|
||||||
const [overview, setOverview] = useState<BillingOverview | null>(null)
|
|
||||||
const [plans, setPlans] = useState<CatalogPlan[]>([])
|
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
// slug currently being acted on (checkout), or "__portal"/"__cancel"
|
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const [ov, pls, invs] = await Promise.all([
|
|
||||||
getBillingOverview(arcadia),
|
|
||||||
listBillingPlans(arcadia).catch(() => [] as CatalogPlan[]),
|
|
||||||
listInvoices(arcadia, { limit: 12 }).catch(() => [] as Invoice[]),
|
|
||||||
])
|
|
||||||
setOverview(ov)
|
|
||||||
setPlans(pls)
|
|
||||||
setInvoices(invs)
|
|
||||||
} catch (e) {
|
|
||||||
setError(
|
|
||||||
e instanceof ArcadiaError || e instanceof Error
|
|
||||||
? e.message
|
|
||||||
: "Failed to load billing details.",
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [arcadia])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const returnUrl =
|
|
||||||
typeof window !== "undefined" ? window.location.href : undefined
|
|
||||||
|
|
||||||
async function handleSubscribe(plan: CatalogPlan) {
|
|
||||||
setBusy(plan.slug)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const { url } = await createCheckoutSession(arcadia, {
|
|
||||||
plan: plan.slug,
|
|
||||||
successUrl: returnUrl,
|
|
||||||
cancelUrl: returnUrl,
|
|
||||||
})
|
|
||||||
window.location.href = url
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Could not start checkout.")
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePortal() {
|
|
||||||
setBusy("__portal")
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const { url } = await createPortalSession(arcadia, { returnUrl })
|
|
||||||
window.location.href = url
|
|
||||||
} catch (e) {
|
|
||||||
setError(
|
|
||||||
e instanceof Error ? e.message : "Could not open the billing portal.",
|
|
||||||
)
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCancel() {
|
|
||||||
if (
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
!window.confirm(
|
|
||||||
"Cancel your subscription at the end of the current period?",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusy("__cancel")
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await cancelSubscription(arcadia)
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
setError(
|
|
||||||
e instanceof Error ? e.message : "Could not cancel the subscription.",
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSlug = overview?.plan
|
|
||||||
const hasActiveSub =
|
|
||||||
overview != null &&
|
|
||||||
overview.plan !== "free" &&
|
|
||||||
["active", "trialing", "past_due"].includes(overview.subscription_status)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -218,237 +26,15 @@ export default function PlanRoute() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="size-4" />
|
|
||||||
<AlertTitle>Something went wrong</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Current plan */}
|
|
||||||
{overview && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<CardTitle>Coming soon</CardTitle>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<CardTitle className="capitalize">
|
|
||||||
{overview.plan} plan
|
|
||||||
</CardTitle>
|
|
||||||
<Badge variant={statusVariant(overview.subscription_status)}>
|
|
||||||
{overview.subscription_status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasActiveSub && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePortal}
|
|
||||||
disabled={busy != null}
|
|
||||||
>
|
|
||||||
{busy === "__portal" ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
)}
|
|
||||||
Manage billing
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasActiveSub && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={busy != null}
|
|
||||||
>
|
|
||||||
{busy === "__cancel" ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{overview.current_period_end
|
Billing is not yet wired to a payment provider on this deployment.
|
||||||
? `Renews ${formatDate(overview.current_period_end)}`
|
|
||||||
: "No active billing period."}
|
|
||||||
{overview.trial_ends_at
|
|
||||||
? ` · Trial ends ${formatDate(overview.trial_ends_at)}`
|
|
||||||
: ""}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{overview.billing_email && (
|
<CardContent />
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
Billing email: {overview.billing_email}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plan picker */}
|
|
||||||
{plans.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-lg font-medium">Available plans</h2>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{plans.map((plan) => {
|
|
||||||
const price = monthlyPricing(plan)
|
|
||||||
const isCurrent = plan.slug === currentSlug
|
|
||||||
const isAud = (price?.currency || "AUD") === "AUD"
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.slug}
|
|
||||||
className={isCurrent ? "border-primary" : undefined}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<CardTitle>{plan.name}</CardTitle>
|
|
||||||
{isCurrent && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Check className="size-3" />
|
|
||||||
Current
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CardDescription>{plan.description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className="text-2xl font-semibold">
|
|
||||||
{price
|
|
||||||
? formatMoney(price.price_cents, price.currency)
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
{price && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
/{price.period}
|
|
||||||
{isAud ? " incl. GST" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{price?.discount_label && (
|
|
||||||
<Badge variant="outline">{price.discount_label}</Badge>
|
|
||||||
)}
|
|
||||||
{plan.trial_days > 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{plan.trial_days}-day free trial
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{plan.meters.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
{plan.meters.slice(0, 5).map((m) => (
|
|
||||||
<li
|
|
||||||
key={m.meter_key}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Check className="size-3 text-primary" />
|
|
||||||
{m.included_units != null
|
|
||||||
? `${m.included_units.toLocaleString()} `
|
|
||||||
: ""}
|
|
||||||
{m.meter_key.replace(/_/g, " ")}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant={isCurrent ? "outline" : "default"}
|
|
||||||
disabled={isCurrent || busy != null}
|
|
||||||
onClick={() => handleSubscribe(plan)}
|
|
||||||
>
|
|
||||||
{busy === plan.slug ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
{isCurrent
|
|
||||||
? "Current plan"
|
|
||||||
: hasActiveSub
|
|
||||||
? `Switch to ${plan.name}`
|
|
||||||
: `Subscribe to ${plan.name}`}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invoices */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Invoices</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Recent billing history for this tenant.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{invoices.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No invoices yet.</p>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
|
||||||
<TableHead className="text-right">Invoice</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{invoices.map((inv, i) => (
|
|
||||||
<TableRow key={inv.id ?? i}>
|
|
||||||
<TableCell>
|
|
||||||
{formatDate(inv.paid_at ?? inv.due_date)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={statusVariant(inv.status ?? "")}>
|
|
||||||
{inv.status ?? "—"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatMoney(inv.amount, inv.currency)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{inv.invoice_url || inv.pdf_url ? (
|
|
||||||
<a
|
|
||||||
href={(inv.invoice_url ?? inv.pdf_url) as string}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
|
||||||
>
|
|
||||||
View <ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
4
start.sh
4
start.sh
@@ -9,7 +9,7 @@ LOG_FILE=".demo.log"
|
|||||||
if [ -f "$PID_FILE" ]; then
|
if [ -f "$PID_FILE" ]; then
|
||||||
existing="$(cat "$PID_FILE")"
|
existing="$(cat "$PID_FILE")"
|
||||||
if [ -n "$existing" ] && kill -0 "$existing" 2>/dev/null; then
|
if [ -n "$existing" ] && kill -0 "$existing" 2>/dev/null; then
|
||||||
echo "crema-app-aifirst-template already running (pid $existing)"
|
echo "arcadia-admin already running (pid $existing)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
@@ -20,4 +20,4 @@ pid=$!
|
|||||||
echo "$pid" >"$PID_FILE"
|
echo "$pid" >"$PID_FILE"
|
||||||
disown "$pid" 2>/dev/null || true
|
disown "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
echo "crema-app-aifirst-template started (pid $pid) — logs: $LOG_FILE"
|
echo "arcadia-admin started (pid $pid) — logs: $LOG_FILE"
|
||||||
|
|||||||
6
stop.sh
6
stop.sh
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
|
|||||||
PID_FILE=".demo.pid"
|
PID_FILE=".demo.pid"
|
||||||
|
|
||||||
if [ ! -f "$PID_FILE" ]; then
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
echo "crema-app-aifirst-template not running (no .demo.pid)"
|
echo "arcadia-admin not running (no .demo.pid)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ pid="$(cat "$PID_FILE")"
|
|||||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
pkill -P "$pid" 2>/dev/null || true
|
pkill -P "$pid" 2>/dev/null || true
|
||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
echo "crema-app-aifirst-template stopped (pid $pid)"
|
echo "arcadia-admin stopped (pid $pid)"
|
||||||
else
|
else
|
||||||
echo "crema-app-aifirst-template pid $pid not alive, cleaning up"
|
echo "arcadia-admin pid $pid not alive, cleaning up"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user