Rebuild the frontend as a product rather than an internal tool
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s

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:
Logan Cusano
2026-08-16 19:34:47 -04:00
parent 6d5eb4c5f2
commit 53965e1a19
35 changed files with 2395 additions and 225 deletions
+105
View File
@@ -0,0 +1,105 @@
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button";
const SECTIONS = [
{
eyebrow: "Correlation",
title: "Calls become incidents",
body:
"The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.",
points: [
"Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak",
"Distance, timing, shared units, and talkgroup signals all feed the match",
"Every call keeps its correlation debug trail for admins to audit",
],
},
{
eyebrow: "AI pipeline",
title: "Transcription and entity extraction",
body:
"Every recorded call is transcribed and scanned for the details that matter — units on scene, vehicles, and locations — so an incident reads like a dispatch briefing instead of a stack of raw audio.",
points: [
"Automatic speech-to-text on every call",
"Scene & entity extraction feeds the correlator and the incident summary",
"AI-generated incident summaries, regenerable on demand",
],
},
{
eyebrow: "Situational awareness",
title: "Live map, full history",
body:
"Glance at the map to see what's active right now, or scrub back through history to review how a specific incident unfolded — every linked call, in order, with playback.",
points: [
"Real-time node and incident map",
"Per-incident call timeline with audio playback",
"Configurable alert rules that post to Discord on keyword or talkgroup match",
],
},
{
eyebrow: "Field hardware",
title: "Field SDR nodes",
body:
"Lightweight edge nodes run OP25/GNU Radio against a P25 or analog trunked system and stream decoded audio to your account. Deploy one node to cover a town, or a whole network across a region.",
points: [
"P25 and analog trunked systems supported",
"Per-node hardware tuning (gain, PPM, antenna) persists independently of system assignment",
"Node health, call activity, and configuration all visible from the dashboard",
],
},
{
eyebrow: "Team",
title: "Discord voice relay & role-scoped access",
body:
"The Discord bot relays live radio audio into a voice channel so your team can listen along without a separate app, and doubles as a lightweight utility bot for team coordination.",
points: [
"Live audio relay per node, on demand",
"Admin / operator / viewer roles, with operators scoped to the nodes they own",
"Discord account linking for in-Discord commands",
],
},
];
export default function FeaturesPage() {
return (
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="max-w-2xl">
<Badge tone="brand">Features</Badge>
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
<p className="text-gray-400 mt-4 leading-relaxed">
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
correlation, mapping, and a live relay — end to end.
</p>
</div>
<div className="mt-16 space-y-16">
{SECTIONS.map((s) => (
<div key={s.title} className="grid grid-cols-1 lg:grid-cols-5 gap-8 items-start">
<div className="lg:col-span-2">
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
</div>
<Card padding="lg" className="lg:col-span-3">
<ul className="space-y-3">
{s.points.map((p) => (
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
{p}
</li>
))}
</ul>
</Card>
</div>
))}
</div>
<div className="text-center mt-20 pt-16 border-t border-gray-800">
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
<div className="mt-6">
<LinkButton href="/login" size="lg">Get started</LinkButton>
</div>
</div>
</div>
);
}