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.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Billing/licensing boundary — STUB MODULE.
|
||||
*
|
||||
* This file defines the typed shape the settings UI (app/settings/billing) talks to.
|
||||
* Nothing here calls a real payment processor. Every exported function returns
|
||||
* hardcoded sample data or throws, and is marked with a TODO describing exactly
|
||||
* what a real integration would do.
|
||||
*
|
||||
* Do NOT wire a real Stripe (or other) publishable/secret key into this file.
|
||||
* When a processor is chosen:
|
||||
* 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side
|
||||
* calls to the processor's API using a secret key from server env — never
|
||||
* exposed to the frontend.
|
||||
* 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an
|
||||
* `organizations/{orgId}` Firestore doc in sync with subscription state
|
||||
* (plan, status, current_period_end, seats, node_limit).
|
||||
* 3. Replace the bodies below with `c2api`-style `fetch` calls into that router.
|
||||
* Checkout/portal functions should return a redirect URL from a real
|
||||
* Checkout/Billing Portal session — the frontend's only job is
|
||||
* `window.location.href = url`, it should never touch card data directly.
|
||||
*/
|
||||
|
||||
export type PlanId = "free" | "pro" | "enterprise";
|
||||
export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none";
|
||||
export type BillingInterval = "monthly" | "annual";
|
||||
|
||||
export interface PlanLimits {
|
||||
seats: number | "unlimited";
|
||||
nodes: number | "unlimited";
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface PlanDefinition {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
priceMonthlyUsd: number | null; // null = "contact us"
|
||||
priceAnnualUsd: number | null;
|
||||
limits: PlanLimits;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
planId: PlanId;
|
||||
status: SubscriptionStatus;
|
||||
interval: BillingInterval;
|
||||
currentPeriodEnd: string | null; // ISO date
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEndsAt: string | null; // ISO date
|
||||
seatsUsed: number;
|
||||
nodesUsed: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: string; // ISO date
|
||||
amountUsd: number;
|
||||
status: "paid" | "open" | "void" | "uncollectible";
|
||||
description: string;
|
||||
/** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */
|
||||
hostedUrl: string | null;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | "unlimited";
|
||||
nodesUsed: number;
|
||||
nodesLimit: number | "unlimited";
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
|
||||
// live pricing table. In a real integration this would likely be fetched
|
||||
// from the processor (Stripe Prices API) instead of hardcoded here so price
|
||||
// changes don't require a frontend deploy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PLANS: PlanDefinition[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Community",
|
||||
tagline: "For a single node and a small crew keeping an eye on local traffic.",
|
||||
priceMonthlyUsd: 0,
|
||||
priceAnnualUsd: 0,
|
||||
limits: { seats: 3, nodes: 1, retentionDays: 7 },
|
||||
features: [
|
||||
"1 field node",
|
||||
"3 team seats",
|
||||
"Live incident map",
|
||||
"7-day call & incident history",
|
||||
"Discord voice relay",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
tagline: "For agencies and serious hobbyist networks running multiple nodes.",
|
||||
priceMonthlyUsd: 79,
|
||||
priceAnnualUsd: 790,
|
||||
limits: { seats: 15, nodes: 10, retentionDays: 90 },
|
||||
features: [
|
||||
"Up to 10 field nodes",
|
||||
"15 team seats",
|
||||
"AI incident correlation & summaries",
|
||||
"90-day call & incident history",
|
||||
"Alert rules with Discord webhooks",
|
||||
"API key access",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
tagline: "For regional networks with custom retention, SSO, and support needs.",
|
||||
priceMonthlyUsd: null,
|
||||
priceAnnualUsd: null,
|
||||
limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 },
|
||||
features: [
|
||||
"Unlimited field nodes",
|
||||
"Unlimited team seats",
|
||||
"1-year+ retention (custom)",
|
||||
"SSO / SAML",
|
||||
"Dedicated support & uptime SLA",
|
||||
"Custom data residency",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPlan(id: PlanId): PlanDefinition {
|
||||
return PLANS.find((p) => p.id === id) ?? PLANS[0];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample account state — clearly a demo fixture, not a real customer record.
|
||||
// TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes
|
||||
// GET /org/subscription backed by the processor + Firestore org doc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_SUBSCRIPTION: Subscription = {
|
||||
planId: "pro",
|
||||
status: "trialing",
|
||||
interval: "monthly",
|
||||
currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
|
||||
seatsUsed: 4,
|
||||
nodesUsed: 2,
|
||||
};
|
||||
|
||||
const SAMPLE_INVOICES: Invoice[] = [
|
||||
{ id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null },
|
||||
];
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription.
|
||||
* Returns sample data so the settings UI has something real to render today.
|
||||
*/
|
||||
export async function getCurrentSubscription(): Promise<Subscription> {
|
||||
return SAMPLE_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage,
|
||||
* computed server-side from `nodes` count + org member count.
|
||||
*/
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
const sub = await getCurrentSubscription();
|
||||
const plan = getPlan(sub.planId);
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return {
|
||||
seatsUsed: sub.seatsUsed,
|
||||
seatsLimit: plan.limits.seats,
|
||||
nodesUsed: sub.nodesUsed,
|
||||
nodesLimit: plan.limits.nodes,
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices,
|
||||
* which on the backend would list Stripe Invoices for the org's customer id
|
||||
* and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`).
|
||||
*/
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return SAMPLE_INVOICES;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)`
|
||||
* → POST /org/billing/checkout-session, which creates a Stripe Checkout
|
||||
* Session server-side (secret key never leaves the server) and returns
|
||||
* `{ url }`. Frontend then does `window.location.href = url`.
|
||||
*
|
||||
* Throws here — there is no live checkout to redirect to.
|
||||
*/
|
||||
export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Checkout is not wired to a payment processor yet. This is a demo build — " +
|
||||
"no card will be charged. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createBillingPortalSession()` →
|
||||
* POST /org/billing/portal-session, which creates a Stripe Billing Portal
|
||||
* session server-side and returns `{ url }` for redirect. The portal is
|
||||
* where a real integration would let customers update payment methods,
|
||||
* cancel, or download invoices — avoids building that UI ourselves.
|
||||
*/
|
||||
export async function createBillingPortalSession(): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` →
|
||||
* GET /org/billing/preview?plan=…, which on the backend would call the
|
||||
* processor's upcoming-invoice/proration preview endpoint.
|
||||
* Returns a rough client-side estimate so the upgrade/downgrade UI has
|
||||
* something to show; not a real proration calculation.
|
||||
*/
|
||||
export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> {
|
||||
const plan = getPlan(planId);
|
||||
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||
return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user