diff --git a/drb-frontend/app/page.tsx b/drb-frontend/app/page.tsx
index a74cb27..db580e9 100644
--- a/drb-frontend/app/page.tsx
+++ b/drb-frontend/app/page.tsx
@@ -1,5 +1,141 @@
-import { redirect } from "next/navigation";
+import Link from "next/link";
+import { LinkButton } from "@/components/ui/Button";
+import { Card } from "@/components/ui/Card";
+import { Badge } from "@/components/ui/Badge";
+import { PLANS } from "@/lib/billing";
-export default function Home() {
- redirect("/dashboard");
+const CAPABILITIES = [
+ {
+ title: "Incidents, not raw calls",
+ body: "Individual transmissions are correlated into a single incident — a pursuit becomes a path through every checkin point, a structure fire becomes a pin at the dispatched address.",
+ },
+ {
+ title: "AI transcription & extraction",
+ body: "Every call is transcribed and scanned for units, vehicles, and locations, so an incident page reads like a briefing instead of a call log.",
+ },
+ {
+ title: "Live map, full history",
+ body: "Watch what's happening right now, or scrub back through history to see how an incident unfolded call by call.",
+ },
+ {
+ title: "Field SDR nodes",
+ body: "Lightweight edge nodes decode P25 and analog police/fire traffic and stream it to your account — deploy one node or a whole regional network.",
+ },
+ {
+ title: "Discord voice relay",
+ body: "Pipe live radio audio into a Discord channel so your team can listen along in real time, no separate scanner app required.",
+ },
+ {
+ title: "Role-scoped access",
+ body: "Admins, operators scoped to the nodes they own, and read-only viewers — invite your team with the access level that fits.",
+ },
+];
+
+const STEPS = [
+ { n: "01", title: "Deploy a node", body: "Point a field SDR node at your local P25 or analog system. It streams decoded audio to your DRB account over the network." },
+ { n: "02", title: "We transcribe & correlate", body: "Calls are transcribed, entities are extracted, and related calls are correlated into incidents automatically." },
+ { n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
+];
+
+export default function MarketingHomePage() {
+ return (
+
+ {/* Hero */}
+
+
+
+
Public-safety radio intelligence
+
+ See what's happening on the radio, as an incident — not a wall of calls.
+
+
+ DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
+ and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
+ bot to relay the audio live to your team.
+
+
+ Get started
+ View pricing
+
+
+
+
+
+ {/* Capabilities */}
+
+
+
The unit of value is the incident
+
+ A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
+ about — and let you click into any one of them for the full picture.
+
+
+
+ {CAPABILITIES.map((c) => (
+
+ {c.title}
+ {c.body}
+
+ ))}
+
+
+
+ {/* How it works */}
+
+
+
How it works
+
+ {STEPS.map((s) => (
+
+
{s.n}
+
{s.title}
+
{s.body}
+
+ ))}
+
+
+
+
+ {/* Pricing teaser */}
+
+
+
+
+
Plans for one node or a whole region
+
Start free. Upgrade when you add nodes or need longer retention.
+
+
+ See full plan comparison →
+
+
+
+ {PLANS.map((plan) => (
+
+ {plan.highlighted && Most popular }
+ {plan.name}
+ {plan.tagline}
+
+ {plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
+ {plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && /mo }
+
+
+ ))}
+
+
+
+
+ {/* Final CTA */}
+
+
+
Bring your first node online
+
+ Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
+
+
+ Get started
+
+
+
+
+ );
}
diff --git a/drb-frontend/app/pricing/page.tsx b/drb-frontend/app/pricing/page.tsx
new file mode 100644
index 0000000..0f7a3d4
--- /dev/null
+++ b/drb-frontend/app/pricing/page.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { PLANS, type BillingInterval } from "@/lib/billing";
+import { Card } from "@/components/ui/Card";
+import { Badge } from "@/components/ui/Badge";
+import { LinkButton } from "@/components/ui/Button";
+
+function CheckIcon() {
+ return (
+
+
+
+ );
+}
+
+export default function PricingPage() {
+ const [interval, setInterval] = useState
("monthly");
+
+ return (
+
+
+
Simple, node-based pricing
+
+ Every plan includes the full incident pipeline — transcription, correlation, mapping, and the Discord relay.
+ Plans differ in how many nodes and seats you get, and how far back your history goes.
+
+
+
+ {/* Interval toggle */}
+
+ {(["monthly", "annual"] as BillingInterval[]).map((i) => (
+ setInterval(i)}
+ className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
+ interval === i ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
+ }`}
+ >
+ {i}
+ {i === "annual" && save ~17% }
+
+ ))}
+
+
+ {/* Plan cards */}
+
+ {PLANS.map((plan) => {
+ const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
+ const priceLabel =
+ price === null ? "Custom" : price === 0 ? "Free" : `$${interval === "annual" ? Math.round(price / 12) : price}`;
+
+ return (
+
+ {plan.highlighted && Most popular }
+ {plan.name}
+ {plan.tagline}
+
+
+
{priceLabel}
+ {price !== null && price > 0 &&
/mo }
+ {interval === "annual" && price !== null && price > 0 && (
+
billed ${plan.priceAnnualUsd}/year
+ )}
+
+
+
+
+ {plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
+
+
+
+
+ {plan.features.map((f) => (
+
+
+ {f}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+ Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
+
+
+
+
+ Questions about a plan?{" "}
+ Check the FAQ
+ {" "}or{" "}
+ sign in to talk to us.
+
+
+
+ );
+}
diff --git a/drb-frontend/app/settings/api-keys/page.tsx b/drb-frontend/app/settings/api-keys/page.tsx
new file mode 100644
index 0000000..c41235b
--- /dev/null
+++ b/drb-frontend/app/settings/api-keys/page.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { EmptyState } from "@/components/ui/EmptyState";
+
+function fmtDate(iso: string) {
+ return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
+}
+
+function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
+ const [name, setName] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [rawKey, setRawKey] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+ try {
+ const { record, rawKey } = await createApiKey(name);
+ onCreated(record);
+ setRawKey(rawKey);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function copy() {
+ if (!rawKey) return;
+ navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
+ }
+
+ if (rawKey) {
+ return (
+
+
+ Key created
+
+ Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
+ lib/apiKeys.ts; it doesn't authenticate against anything.
+
+
+
+ {copied ? "Copied!" : "Copy key"}
+ Done
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default function ApiKeysSettingsPage() {
+ const [keys, setKeys] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+
+ useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
+
+ async function handleRevoke(id: string) {
+ await revokeApiKey(id);
+ setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
+ }
+
+ const active = keys.filter((k) => !k.revoked);
+
+ return (
+
+
+
Preview feature
+
+ Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
+ demo module (lib/apiKeys.ts) so the flow can be reviewed end to
+ end. See that file for the exact backend routes a real integration needs.
+
+
+
+ {showCreate && (
+
setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
+ )}
+
+
+
{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}
+
setShowCreate(true)}>+ Create key
+
+
+ {!loading && keys.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Label
+ Key
+ Created
+ Last used
+ Status
+
+
+
+
+ {keys.map((k) => (
+
+ {k.name}
+ {k.key_prefix}…
+ {fmtDate(k.created_at)}
+ {k.last_used_at ? fmtDate(k.last_used_at) : "Never"}
+
+ {k.revoked ? Revoked : Active }
+
+
+ {!k.revoked && (
+ handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
+ Revoke
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/drb-frontend/app/settings/billing/page.tsx b/drb-frontend/app/settings/billing/page.tsx
new file mode 100644
index 0000000..292172e
--- /dev/null
+++ b/drb-frontend/app/settings/billing/page.tsx
@@ -0,0 +1,214 @@
+"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 (
+
+
+ Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
+
+
Trial
+
+ );
+ }
+ if (sub.status === "past_due") {
+ return (
+
+
+ Payment failed on your last invoice. Update your payment method to avoid losing access.
+
+
Past due
+
+ );
+ }
+ if (sub.status === "canceled") {
+ return (
+
+
Your subscription is canceled. Reactivate to restore full access.
+
Canceled
+
+ );
+ }
+ 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 (
+
+
+ {label}
+
+ {used} / {limit === "unlimited" ? "∞" : limit}
+
+
+
+
+ );
+}
+
+const INVOICE_TONE: Record = {
+ paid: "success",
+ open: "warning",
+ void: "neutral",
+ uncollectible: "danger",
+};
+
+export default function BillingSettingsPage() {
+ const [sub, setSub] = useState(null);
+ const [usage, setUsage] = useState(null);
+ const [invoices, setInvoices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [actionError, setActionError] = useState(null);
+ const [busyPlan, setBusyPlan] = useState(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 (
+
+
+
+ );
+ }
+
+ const plan = getPlan(sub.planId);
+
+ return (
+
+
+ Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
+
+
+
+
+ {actionError && (
+
+ )}
+
+
+ {plan.name}}
+ />
+
+
+
+
+
+
+ {portalBusy ? "Opening…" : "Manage payment method & invoices"}
+
+
+
+
+
+
+
+ {PLANS.map((p) => {
+ const isCurrent = p.id === sub.planId;
+ return (
+
+
{p.name}
+
{p.tagline}
+
+ {p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
+
+
handleChoosePlan(p.id)}
+ fullWidth
+ >
+ {isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
+
+
+ );
+ })}
+
+
+
+
+
+ {invoices.length === 0 ? (
+ No invoices yet.
+ ) : (
+
+ {invoices.map((inv) => (
+
+
+
{inv.description}
+
{fmtDate(inv.date)}
+
+
+ ${inv.amountUsd.toFixed(2)}
+ {inv.status}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/drb-frontend/app/settings/layout.tsx b/drb-frontend/app/settings/layout.tsx
new file mode 100644
index 0000000..34cf352
--- /dev/null
+++ b/drb-frontend/app/settings/layout.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useAuth } from "@/components/AuthProvider";
+import { PageHeader } from "@/components/ui/PageHeader";
+
+const TABS = [
+ { href: "/settings/organization", label: "Organization" },
+ { href: "/settings/members", label: "Members" },
+ { href: "/settings/nodes", label: "Node Ownership" },
+ { href: "/settings/api-keys", label: "API Keys" },
+ { href: "/settings/billing", label: "Billing" },
+];
+
+export default function SettingsLayout({ children }: { children: React.ReactNode }) {
+ const { isAdmin, loading } = useAuth();
+ const pathname = usePathname();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!loading && !isAdmin) router.replace("/dashboard");
+ }, [loading, isAdmin, router]);
+
+ if (loading || !isAdmin) return null;
+
+ return (
+
+
+
+
+ {TABS.map((t) => (
+
+ {t.label}
+
+ ))}
+
+
+ {children}
+
+ );
+}
diff --git a/drb-frontend/app/settings/members/page.tsx b/drb-frontend/app/settings/members/page.tsx
new file mode 100644
index 0000000..2ed330f
--- /dev/null
+++ b/drb-frontend/app/settings/members/page.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { c2api } from "@/lib/c2api";
+import { useAuth } from "@/components/AuthProvider";
+import type { UserRecord, UserRole } from "@/lib/types";
+import { Card } from "@/components/ui/Card";
+import { Badge } from "@/components/ui/Badge";
+import { Button } from "@/components/ui/Button";
+import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
+import { SkeletonRow } from "@/components/ui/Skeleton";
+
+const ROLE_TONE: Record = {
+ admin: "brand",
+ operator: "success",
+ viewer: "neutral",
+};
+
+const ROLE_LABEL: Record = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
+
+function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
+ const [email, setEmail] = useState("");
+ const [role, setRole] = useState("viewer");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [inviteLink, setInviteLink] = useState(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+ setError(null);
+ try {
+ const created = await c2api.createUser({ email, role });
+ onCreated(created);
+ if (created.invite_link) setInviteLink(created.invite_link);
+ else onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (inviteLink) {
+ return (
+
+
+ Member invited
+ Share this one-time invite link so they can set their password. It expires after use.
+
+ Done
+
+
+ );
+ }
+
+ return (
+
+
+ Invite a member
+
+
+
+ );
+}
+
+export default function MembersSettingsPage() {
+ const { user } = useAuth();
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showInvite, setShowInvite] = useState(false);
+ const [savingUid, setSavingUid] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ setUsers(await c2api.listUsers());
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ async function handleRoleChange(u: UserRecord, role: UserRole) {
+ setSavingUid(u.uid);
+ try {
+ const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
+ setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setSavingUid(null);
+ }
+ }
+
+ return (
+
+ {showInvite && (
+
setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
+ )}
+
+
+
+ {loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
+
+
setShowInvite(true)}>+ Invite member
+
+
+ {error && }
+
+ {!loading && users.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Member
+ Role
+ Owned nodes
+ Status
+
+
+
+ {loading
+ ? Array.from({ length: 3 }).map((_, i) => )
+ : users.map((u) => (
+
+
+ {u.display_name || u.email}
+ {u.display_name && {u.email}
}
+
+
+ {u.uid === user?.uid ? (
+ {ROLE_LABEL[u.role]}
+ ) : (
+ handleRoleChange(u, e.target.value as UserRole)}
+ className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
+ >
+ Admin
+ Operator
+ Viewer
+
+ )}
+
+
+ {u.role === "operator" ? u.owned_node_ids.length : "—"}
+
+
+ {u.disabled ? Disabled : Active }
+
+
+ ))}
+
+
+
+ )}
+
+
+ Need to disable or delete a member? Use the full user admin panel under Admin → Users.
+
+
+ );
+}
diff --git a/drb-frontend/app/settings/nodes/page.tsx b/drb-frontend/app/settings/nodes/page.tsx
new file mode 100644
index 0000000..a36cf38
--- /dev/null
+++ b/drb-frontend/app/settings/nodes/page.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import { useNodes } from "@/lib/useNodes";
+import { c2api } from "@/lib/c2api";
+import type { UserRecord } from "@/lib/types";
+import { StatusBadge } from "@/components/StatusBadge";
+import { Card } from "@/components/ui/Card";
+import { ErrorBanner } from "@/components/ui/EmptyState";
+import { SkeletonRow } from "@/components/ui/Skeleton";
+
+const UNASSIGNED = "__unassigned__";
+
+export default function NodeOwnershipSettingsPage() {
+ const { nodes, loading: nodesLoading } = useNodes();
+ const [users, setUsers] = useState([]);
+ const [loadingUsers, setLoadingUsers] = useState(true);
+ const [error, setError] = useState(null);
+ const [savingNodeId, setSavingNodeId] = useState(null);
+
+ useEffect(() => {
+ c2api.listUsers()
+ .then(setUsers)
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
+ .finally(() => setLoadingUsers(false));
+ }, []);
+
+ // Ownership (owned_node_ids) is only meaningful for operators elsewhere in the
+ // app (see Admin → Users); admins already have full access regardless.
+ const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]);
+
+ const ownerByNode = useMemo(() => {
+ const map = new Map();
+ for (const u of users) {
+ if (u.role !== "operator") continue;
+ for (const nodeId of u.owned_node_ids) map.set(nodeId, u);
+ }
+ return map;
+ }, [users]);
+
+ const reassign = useCallback(async (nodeId: string, newUid: string) => {
+ setSavingNodeId(nodeId);
+ setError(null);
+ try {
+ const prevOwner = ownerByNode.get(nodeId);
+ // Remove from previous owner, if any and different from the new one.
+ if (prevOwner && prevOwner.uid !== newUid) {
+ const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId);
+ await c2api.updateUser(prevOwner.uid, { owned_node_ids: next });
+ setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u)));
+ }
+ // Add to new owner, if one was selected.
+ if (newUid !== UNASSIGNED) {
+ const newOwner = users.find((u) => u.uid === newUid);
+ if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) {
+ const next = [...newOwner.owned_node_ids, nodeId];
+ await c2api.updateUser(newOwner.uid, { owned_node_ids: next });
+ setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u)));
+ }
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setSavingNodeId(null);
+ }
+ }, [ownerByNode, users]);
+
+ const loading = nodesLoading || loadingUsers;
+
+ return (
+
+
+ Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
+
+
+ {error &&
}
+
+
+
+
+
+ Node
+ Status
+ Owner
+
+
+
+ {loading ? (
+ Array.from({ length: 3 }).map((_, i) => )
+ ) : nodes.length === 0 ? (
+ No nodes registered yet.
+ ) : (
+ nodes.map((n) => {
+ const owner = ownerByNode.get(n.node_id);
+ return (
+
+
+
+ {n.name}
+
+ {n.node_id}
+
+
+
+ reassign(n.node_id, e.target.value)}
+ className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
+ >
+ Unassigned
+ {assignable.map((u) => (
+ {u.display_name || u.email}
+ ))}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/drb-frontend/app/settings/organization/page.tsx b/drb-frontend/app/settings/organization/page.tsx
new file mode 100644
index 0000000..b177773
--- /dev/null
+++ b/drb-frontend/app/settings/organization/page.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { c2api } from "@/lib/c2api";
+import { useNodes } from "@/lib/useNodes";
+import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
+import { Card, CardHeader } from "@/components/ui/Card";
+import { Badge } from "@/components/ui/Badge";
+import { Button } from "@/components/ui/Button";
+import { Skeleton } from "@/components/ui/Skeleton";
+
+function StatTile({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
+
+export default function OrganizationSettingsPage() {
+ const { nodes } = useNodes();
+ const [memberCount, setMemberCount] = useState(null);
+ const [sub, setSub] = useState(null);
+ const [orgName, setOrgName] = useState("My Organization");
+
+ useEffect(() => {
+ c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
+ getCurrentSubscription().then(setSub);
+ }, []);
+
+ const plan = sub ? getPlan(sub.planId) : null;
+
+ return (
+
+
+
+
+
+ Organization name
+ setOrgName(e.target.value)}
+ className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
+ />
+
+
+
+ Save changes
+
+
+ Preview only — no backend endpoint stores this yet.
+
+
+
+
+
+
+ {plan.name} plan
+ ) : (
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+ Manage plan & billing →
+
+
+ Manage members →
+
+
+
+
+
+
+
+
+ Delete organization
+
+
+ Transfer ownership
+
+
+
+
+ );
+}
diff --git a/drb-frontend/app/settings/page.tsx b/drb-frontend/app/settings/page.tsx
new file mode 100644
index 0000000..325c8c2
--- /dev/null
+++ b/drb-frontend/app/settings/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function SettingsIndexPage() {
+ redirect("/settings/organization");
+}
diff --git a/drb-frontend/components/CallRow.tsx b/drb-frontend/components/CallRow.tsx
index 32d8a70..e017ea8 100644
--- a/drb-frontend/components/CallRow.tsx
+++ b/drb-frontend/components/CallRow.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api";
+import { severityBadge } from "@/lib/severity";
interface Props {
call: CallRecord;
@@ -98,6 +99,10 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.tags[0]}
)}
+ {/* Routine/minor are the majority of traffic and stay unbadged; moderate+ is the triage signal worth a badge in a dense list. */}
+ {(call.severity === "moderate" || call.severity === "major") && (
+ {severityBadge(call.severity)}
+ )}
{systemName ?? call.system_id ?? "—"}
{call.node_id}
diff --git a/drb-frontend/components/ChromeSwitcher.tsx b/drb-frontend/components/ChromeSwitcher.tsx
new file mode 100644
index 0000000..20034d8
--- /dev/null
+++ b/drb-frontend/components/ChromeSwitcher.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { Nav } from "@/components/Nav";
+import { MarketingHeader } from "@/components/marketing/MarketingHeader";
+import { MarketingFooter } from "@/components/marketing/MarketingFooter";
+
+// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
+// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
+const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
+
+/**
+ * Picks page chrome by route: the public marketing pages get a full-bleed
+ * layout with their own header/footer, everything else (the authenticated
+ * app, including /login and /settings) keeps the existing app Nav + padded
+ * main container.
+ */
+export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+
+ if (MARKETING_PATHS.has(pathname)) {
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/drb-frontend/components/IncidentBadges.tsx b/drb-frontend/components/IncidentBadges.tsx
new file mode 100644
index 0000000..5a2da71
--- /dev/null
+++ b/drb-frontend/components/IncidentBadges.tsx
@@ -0,0 +1,21 @@
+// Shared incident "type" badge — used on the incidents list, incident detail,
+// and the dashboard's active-incidents panel. `other` covers anything outside
+// the four radio-traffic archetypes (rail ops, public works, utility
+// coordination, …) and must always resolve to a styled badge, never fall
+// through unstyled.
+const TYPE_COLORS: Record = {
+ fire: "bg-red-900 text-red-300",
+ police: "bg-blue-900 text-blue-300",
+ ems: "bg-yellow-900 text-yellow-300",
+ accident: "bg-orange-900 text-orange-300",
+ other: "bg-gray-800 text-gray-300",
+};
+
+export function TypeBadge({ type }: { type: string | null }) {
+ const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
+ return (
+
+ {type ?? "other"}
+
+ );
+}
diff --git a/drb-frontend/components/Nav.tsx b/drb-frontend/components/Nav.tsx
index 08bc08c..b79dc01 100644
--- a/drb-frontend/components/Nav.tsx
+++ b/drb-frontend/components/Nav.tsx
@@ -28,6 +28,7 @@ const operatorLinks = [
// Admin-only links
const adminLinks = [
{ href: "/admin", label: "Admin" },
+ { href: "/settings", label: "Settings" },
];
function SunIcon() {
diff --git a/drb-frontend/components/marketing/MarketingFooter.tsx b/drb-frontend/components/marketing/MarketingFooter.tsx
new file mode 100644
index 0000000..bfad285
--- /dev/null
+++ b/drb-frontend/components/marketing/MarketingFooter.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+
+export function MarketingFooter() {
+ return (
+
+ );
+}
diff --git a/drb-frontend/components/marketing/MarketingHeader.tsx b/drb-frontend/components/marketing/MarketingHeader.tsx
new file mode 100644
index 0000000..63d7006
--- /dev/null
+++ b/drb-frontend/components/marketing/MarketingHeader.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useAuth } from "@/components/AuthProvider";
+import { LinkButton } from "@/components/ui/Button";
+
+const LINKS = [
+ { href: "/features", label: "Features" },
+ { href: "/pricing", label: "Pricing" },
+ { href: "/faq", label: "FAQ" },
+];
+
+export function MarketingHeader() {
+ const pathname = usePathname();
+ const { user, loading } = useAuth();
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ return (
+
+
+
+
D
+ DRB
+
+
+
+ {LINKS.map(({ href, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+ {!loading && user ? (
+ Go to dashboard
+ ) : (
+ <>
+ Sign in
+ Get started
+ >
+ )}
+
+
+
setMobileOpen((v) => !v)}
+ className="md:hidden ml-auto text-gray-400 hover:text-gray-200 transition-colors p-1"
+ aria-label="Toggle menu"
+ >
+ {mobileOpen ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {mobileOpen && (
+
+ {LINKS.map(({ href, label }) => (
+
setMobileOpen(false)}
+ className={`py-2 text-sm font-mono transition-colors ${pathname === href ? "text-white" : "text-gray-400"}`}
+ >
+ {label}
+
+ ))}
+
+ {!loading && user ? (
+ Go to dashboard
+ ) : (
+ <>
+ Sign in
+ Get started
+ >
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/drb-frontend/components/ui/Badge.tsx b/drb-frontend/components/ui/Badge.tsx
new file mode 100644
index 0000000..66585e4
--- /dev/null
+++ b/drb-frontend/components/ui/Badge.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+
+type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
+
+const TONE_CLASSES: Record = {
+ neutral: "bg-gray-800 text-gray-300",
+ brand: "bg-indigo-900 text-indigo-300",
+ success: "bg-green-900 text-green-300",
+ warning: "bg-yellow-900 text-yellow-300",
+ danger: "bg-red-900 text-red-300",
+ info: "bg-blue-900 text-blue-300",
+};
+
+export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/drb-frontend/components/ui/Button.tsx b/drb-frontend/components/ui/Button.tsx
new file mode 100644
index 0000000..13e66a9
--- /dev/null
+++ b/drb-frontend/components/ui/Button.tsx
@@ -0,0 +1,76 @@
+import Link from "next/link";
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+
+type Variant = "primary" | "secondary" | "ghost" | "danger";
+type Size = "sm" | "md" | "lg";
+
+const VARIANT_CLASSES: Record = {
+ primary:
+ "bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600",
+ secondary:
+ "bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800",
+ ghost:
+ "bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent",
+ danger:
+ "bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700",
+};
+
+const SIZE_CLASSES: Record = {
+ sm: "text-xs px-3 py-1.5 rounded-lg gap-1.5",
+ md: "text-sm px-4 py-2 rounded-lg gap-2",
+ lg: "text-sm px-5 py-2.5 rounded-xl gap-2",
+};
+
+const BASE =
+ "inline-flex items-center justify-center font-semibold font-mono transition-colors " +
+ "disabled:opacity-50 disabled:cursor-not-allowed " +
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950";
+
+interface CommonProps {
+ variant?: Variant;
+ size?: Size;
+ children: ReactNode;
+ className?: string;
+ fullWidth?: boolean;
+}
+
+type ButtonProps = CommonProps &
+ ButtonHTMLAttributes & {
+ href?: undefined;
+ };
+
+interface LinkButtonProps extends CommonProps {
+ href: string;
+ external?: boolean;
+}
+
+function classes(variant: Variant, size: Size, fullWidth: boolean | undefined, extra?: string) {
+ return [BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], fullWidth ? "w-full" : "", extra ?? ""]
+ .filter(Boolean)
+ .join(" ");
+}
+
+/** Button — use for in-page actions. Pass `href` instead to render a Link (see LinkButton export). */
+export function Button({ variant = "primary", size = "md", children, className, fullWidth, ...rest }: ButtonProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Same visual language as Button, but renders a Next.js Link — for navigation, not actions. */
+export function LinkButton({ variant = "primary", size = "md", children, className, fullWidth, href, external }: LinkButtonProps) {
+ if (external) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/drb-frontend/components/ui/Card.tsx b/drb-frontend/components/ui/Card.tsx
new file mode 100644
index 0000000..5683550
--- /dev/null
+++ b/drb-frontend/components/ui/Card.tsx
@@ -0,0 +1,47 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+interface CardProps extends HTMLAttributes {
+ children: ReactNode;
+ hover?: boolean;
+ padding?: "none" | "sm" | "md" | "lg";
+ highlighted?: boolean;
+}
+
+const PADDING: Record, string> = {
+ none: "",
+ sm: "p-4",
+ md: "p-5",
+ lg: "p-8",
+};
+
+/** Standard surface card — the base container used across app + settings + marketing. */
+export function Card({ children, hover, padding = "md", highlighted, className, ...rest }: CardProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {action &&
{action}
}
+
+ );
+}
diff --git a/drb-frontend/components/ui/EmptyState.tsx b/drb-frontend/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..5f50524
--- /dev/null
+++ b/drb-frontend/components/ui/EmptyState.tsx
@@ -0,0 +1,29 @@
+import type { ReactNode } from "react";
+
+interface EmptyStateProps {
+ icon?: ReactNode;
+ title: string;
+ description?: string;
+ action?: ReactNode;
+}
+
+/** Consistent "nothing here yet" panel — replaces the ad-hoc `` scattered across pages. */
+export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
+ return (
+
+ {icon &&
{icon}
}
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ );
+}
+
+/** Inline error banner — for API/Firestore errors surfaced within a page section. */
+export function ErrorBanner({ message }: { message: string }) {
+ return (
+
+ );
+}
diff --git a/drb-frontend/components/ui/PageHeader.tsx b/drb-frontend/components/ui/PageHeader.tsx
new file mode 100644
index 0000000..68d5006
--- /dev/null
+++ b/drb-frontend/components/ui/PageHeader.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+
+interface PageHeaderProps {
+ title: ReactNode;
+ description?: ReactNode;
+ badge?: ReactNode;
+ action?: ReactNode;
+}
+
+/** Standard page title row — title + optional badge on the left, primary action on the right. */
+export function PageHeader({ title, description, badge, action }: PageHeaderProps) {
+ return (
+
+
+
+
{title}
+ {badge}
+
+ {description &&
{description}
}
+
+ {action &&
{action}
}
+
+ );
+}
diff --git a/drb-frontend/components/ui/Skeleton.tsx b/drb-frontend/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..ca6325a
--- /dev/null
+++ b/drb-frontend/components/ui/Skeleton.tsx
@@ -0,0 +1,27 @@
+/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
+ * content has a predictable shape (cards, table rows, stat tiles). */
+export function Skeleton({ className }: { className?: string }) {
+ return
;
+}
+
+export function SkeletonCard() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function SkeletonRow({ cols = 5 }: { cols?: number }) {
+ return (
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/drb-frontend/lib/apiKeys.ts b/drb-frontend/lib/apiKeys.ts
new file mode 100644
index 0000000..0da9630
--- /dev/null
+++ b/drb-frontend/lib/apiKeys.ts
@@ -0,0 +1,73 @@
+/**
+ * Organization API keys — STUB MODULE, no backend endpoint exists yet.
+ *
+ * This is a distinct concept from the two API-key-shaped things that already
+ * exist server-side:
+ * - `node_keys` (Firestore collection) — per-node upload credentials, issued
+ * via /nodes/{id}/reissue-key. Not this.
+ * - The Discord bot token pool (app/tokens) — Discord bot tokens, not this.
+ *
+ * This module models organization-level API keys for third-party
+ * integrations (a standard SaaS feature) that DRB does not yet expose.
+ * Everything below is in-memory demo state so the settings UI has something
+ * real to render; nothing here is persisted or capable of authenticating
+ * against the real API.
+ *
+ * TODO(api-keys): to make this real, add to drb-c2-core:
+ * - `org_api_keys` Firestore collection: {key_id, org_id, name, key_hash,
+ * key_prefix, created_at, last_used_at, created_by_uid, revoked}
+ * - POST /org/api-keys → generate, return the raw key ONCE
+ * - GET /org/api-keys → list (prefix + metadata only, never the raw key)
+ * - DELETE /org/api-keys/{id} → revoke
+ * - A new auth path in internal/auth.py that checks `Authorization: Bearer drb_live_…`
+ * against `key_hash` (constant-time compare), scoped like a viewer/operator token.
+ * Then replace the functions below with c2api calls hitting those routes.
+ */
+
+export interface ApiKeyRecord {
+ key_id: string;
+ name: string;
+ /** Only the prefix is ever shown after creation — mirrors how real key systems (Stripe, GitHub) do it. */
+ key_prefix: string;
+ created_at: string;
+ last_used_at: string | null;
+ revoked: boolean;
+}
+
+// Sample/demo fixture — obviously not real keys, never sent anywhere.
+let DEMO_KEYS: ApiKeyRecord[] = [
+ {
+ key_id: "demo_key_1",
+ name: "Ops dashboard integration",
+ key_prefix: "drb_live_sample_4f2a",
+ created_at: "2026-07-02T14:00:00.000Z",
+ last_used_at: "2026-08-15T09:12:00.000Z",
+ revoked: false,
+ },
+];
+
+export async function listApiKeys(): Promise {
+ return DEMO_KEYS;
+}
+
+/**
+ * Returns the full (fake) key exactly once, same UX contract a real key
+ * issuance flow would have — the raw secret is shown once and never again.
+ */
+export async function createApiKey(name: string): Promise<{ record: ApiKeyRecord; rawKey: string }> {
+ const suffix = Math.random().toString(36).slice(2, 10);
+ const record: ApiKeyRecord = {
+ key_id: `demo_key_${DEMO_KEYS.length + 1}`,
+ name,
+ key_prefix: `drb_live_sample_${suffix.slice(0, 4)}`,
+ created_at: new Date().toISOString(),
+ last_used_at: null,
+ revoked: false,
+ };
+ DEMO_KEYS = [...DEMO_KEYS, record];
+ return { record, rawKey: `drb_live_sample_${suffix}_DEMO_NOT_A_REAL_KEY` };
+}
+
+export async function revokeApiKey(keyId: string): Promise {
+ DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
+}
diff --git a/drb-frontend/lib/billing.ts b/drb-frontend/lib/billing.ts
new file mode 100644
index 0000000..8a10539
--- /dev/null
+++ b/drb-frontend/lib/billing.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/drb-frontend/lib/severity.tsx b/drb-frontend/lib/severity.tsx
new file mode 100644
index 0000000..6406530
--- /dev/null
+++ b/drb-frontend/lib/severity.tsx
@@ -0,0 +1,36 @@
+/**
+ * Shared severity ladder for calls and incidents: routine < minor < moderate < major.
+ * Every call/incident gets one of these four. `"unknown"` (and any other
+ * unrecognized value) is a legacy value still present on historical docs —
+ * treat it as "no severity", not as a fifth level.
+ */
+import type { ReactElement } from "react";
+
+export type Severity = "routine" | "minor" | "moderate" | "major";
+
+export const SEVERITY_ORDER: Record = { routine: 0, minor: 1, moderate: 2, major: 3 };
+export const SEVERITY_LABEL: Record = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
+export const SEVERITY_COLORS: Record = {
+ routine: "bg-gray-800/40 text-gray-600",
+ minor: "bg-gray-800 text-gray-400",
+ moderate: "bg-orange-950 text-orange-400",
+ major: "bg-red-950 text-red-400",
+};
+
+export function isKnownSeverity(s: string | null | undefined): s is Severity {
+ return s === "routine" || s === "minor" || s === "moderate" || s === "major";
+}
+
+/** Legacy/unset severities rank below `routine` so a recency-sorted list never confuses them with a real (low) severity. */
+export function severityRank(s: string | null | undefined): number {
+ return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
+}
+
+export function severityBadge(severity: string | null | undefined): ReactElement | null {
+ if (!isKnownSeverity(severity)) return null;
+ return (
+
+ {SEVERITY_LABEL[severity]}
+
+ );
+}
diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts
index 38eaf3a..0eb32a6 100644
--- a/drb-frontend/lib/types.ts
+++ b/drb-frontend/lib/types.ts
@@ -111,6 +111,8 @@ export interface CallRecord {
location: string | null;
tags: string[];
status: "active" | "ended";
+ /** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
+ severity?: string | null;
// Correlation debug — written by the correlator, present after a call is linked
corr_path?: string | null;
corr_score?: number | null;
diff --git a/drb-frontend/middleware.ts b/drb-frontend/middleware.ts
index 37f7481..3577c3a 100644
--- a/drb-frontend/middleware.ts
+++ b/drb-frontend/middleware.ts
@@ -1,9 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
+// Public marketing pages — no session required. Keep this in sync with
+// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
+// chrome; this one decides whether to redirect at all).
+const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
+
+// NOTE: this is a UX redirect only, not a security boundary — it just checks
+// a client-set cookie's presence. Real enforcement is server-side, in
+// drb-c2-core/app/internal/auth.py. See CLAUDE.md.
export function middleware(request: NextRequest) {
const session = request.cookies.get("drb_session");
const { pathname } = request.nextUrl;
+ if (PUBLIC_PATHS.has(pathname)) {
+ return NextResponse.next();
+ }
+
if (pathname === "/login") {
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
return NextResponse.next();
diff --git a/drb-frontend/tailwind.config.ts b/drb-frontend/tailwind.config.ts
index 50cdbc7..1fcc8a5 100644
--- a/drb-frontend/tailwind.config.ts
+++ b/drb-frontend/tailwind.config.ts
@@ -10,6 +10,28 @@ const config: Config = {
extend: {
fontFamily: {
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"],
+ sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
+ },
+ // Marketing/product type scale — used by the (marketing) surface and
+ // settings shell so headings read as a deliberate hierarchy rather than
+ // ad-hoc text-xl/text-2xl bumps.
+ fontSize: {
+ "display-lg": ["3.5rem", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
+ "display": ["2.75rem", { lineHeight: "1.1", letterSpacing: "-0.02em", fontWeight: "700" }],
+ "display-sm": ["2.125rem", { lineHeight: "1.15", letterSpacing: "-0.01em", fontWeight: "700" }],
+ },
+ boxShadow: {
+ card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
+ "card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
+ glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
+ },
+ animation: {
+ "fade-in": "fade-in 0.4s ease-out",
+ "slide-up": "slide-up 0.4s ease-out",
+ },
+ keyframes: {
+ "fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
+ "slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
},
},
},