Files
server-26/drb-frontend/app/settings/billing/page.tsx
T
Logan Cusano 53965e1a19
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
Rebuild the frontend as a product rather than an internal tool
The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.

A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.

Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.

Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.

The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.

Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.

Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
2026-08-16 19:34:47 -04:00

215 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import {
PLANS, getPlan, getCurrentSubscription, getUsageSummary, getInvoices,
createCheckoutSession, createBillingPortalSession,
type Subscription, type UsageSummary, type Invoice, type PlanId,
} from "@/lib/billing";
import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { SkeletonCard } from "@/components/ui/Skeleton";
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function StatusBanner({ sub }: { sub: Subscription }) {
if (sub.status === "trialing" && sub.trialEndsAt) {
return (
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-indigo-300">
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
</p>
<Badge tone="brand">Trial</Badge>
</div>
);
}
if (sub.status === "past_due") {
return (
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-red-400">
Payment failed on your last invoice. Update your payment method to avoid losing access.
</p>
<Badge tone="danger">Past due</Badge>
</div>
);
}
if (sub.status === "canceled") {
return (
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
<Badge tone="warning">Canceled</Badge>
</div>
);
}
return null;
}
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number | "unlimited" }) {
const pct = limit === "unlimited" ? 0 : Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
const nearLimit = limit !== "unlimited" && used / limit >= 0.9;
return (
<div>
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs text-gray-400 font-mono">{label}</span>
<span className="text-xs font-mono text-gray-300">
{used} / {limit === "unlimited" ? "∞" : limit}
</span>
</div>
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
/>
</div>
</div>
);
}
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
paid: "success",
open: "warning",
void: "neutral",
uncollectible: "danger",
};
export default function BillingSettingsPage() {
const [sub, setSub] = useState<Subscription | null>(null);
const [usage, setUsage] = useState<UsageSummary | null>(null);
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState<string | null>(null);
const [busyPlan, setBusyPlan] = useState<PlanId | null>(null);
const [portalBusy, setPortalBusy] = useState(false);
useEffect(() => {
Promise.all([getCurrentSubscription(), getUsageSummary(), getInvoices()])
.then(([s, u, i]) => { setSub(s); setUsage(u); setInvoices(i); })
.finally(() => setLoading(false));
}, []);
async function handleChoosePlan(planId: PlanId) {
setBusyPlan(planId);
setActionError(null);
try {
const { url } = await createCheckoutSession(planId, sub?.interval ?? "monthly");
window.location.href = url;
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setBusyPlan(null);
}
}
async function handleManageBilling() {
setPortalBusy(true);
setActionError(null);
try {
const { url } = await createBillingPortalSession();
window.location.href = url;
} catch (e) {
setActionError(e instanceof Error ? e.message : String(e));
} finally {
setPortalBusy(false);
}
}
if (loading || !sub || !usage) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SkeletonCard /><SkeletonCard />
</div>
);
}
const plan = getPlan(sub.planId);
return (
<div className="space-y-6 max-w-4xl">
<p className="text-xs text-gray-600 font-mono">
Demo data — this page isn&apos;t connected to a live payment processor. See lib/billing.ts for the integration plan.
</p>
<StatusBanner sub={sub} />
{actionError && (
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
<p className="text-red-400 text-sm">{actionError}</p>
</div>
)}
<Card>
<CardHeader
title="Current plan"
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
</div>
<div className="mt-5 pt-5 border-t border-gray-800">
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
</Button>
</div>
</Card>
<Card>
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{PLANS.map((p) => {
const isCurrent = p.id === sub.planId;
return (
<div
key={p.id}
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
>
<p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
<p className="text-white text-lg font-bold font-mono mt-3">
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
</p>
<Button
className="mt-3"
size="sm"
variant={isCurrent ? "secondary" : "primary"}
disabled={isCurrent || busyPlan === p.id}
onClick={() => handleChoosePlan(p.id)}
fullWidth
>
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
</Button>
</div>
);
})}
</div>
</Card>
<Card>
<CardHeader title="Invoice history" />
{invoices.length === 0 ? (
<p className="text-gray-600 text-sm">No invoices yet.</p>
) : (
<div className="divide-y divide-gray-800">
{invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<p className="text-gray-200 text-sm">{inv.description}</p>
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}