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.
142 lines
6.8 KiB
TypeScript
142 lines
6.8 KiB
TypeScript
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";
|
|
|
|
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 (
|
|
<div>
|
|
{/* Hero */}
|
|
<section className="marketing-hero-bg">
|
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 pt-20 pb-24 md:pt-28 md:pb-32">
|
|
<div className="max-w-3xl">
|
|
<Badge tone="brand">Public-safety radio intelligence</Badge>
|
|
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
|
See what's happening on the radio, as an incident — not a wall of calls.
|
|
</h1>
|
|
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
|
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.
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-3 mt-8">
|
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
|
<LinkButton href="/pricing" variant="secondary" size="lg">View pricing</LinkButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Capabilities */}
|
|
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
|
<div className="max-w-2xl mb-10">
|
|
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
|
<p className="text-gray-400 mt-3">
|
|
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.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{CAPABILITIES.map((c) => (
|
|
<Card key={c.title} padding="lg" hover>
|
|
<h3 className="text-white font-semibold">{c.title}</h3>
|
|
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* How it works */}
|
|
<section className="border-t border-gray-800">
|
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
|
<h2 className="text-display-sm text-white mb-10">How it works</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
{STEPS.map((s) => (
|
|
<div key={s.n}>
|
|
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
|
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
|
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Pricing teaser */}
|
|
<section className="border-t border-gray-800">
|
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
|
|
<div>
|
|
<h2 className="text-display-sm text-white">Plans for one node or a whole region</h2>
|
|
<p className="text-gray-400 mt-2">Start free. Upgrade when you add nodes or need longer retention.</p>
|
|
</div>
|
|
<Link href="/pricing" className="text-indigo-400 hover:text-indigo-300 text-sm font-mono transition-colors shrink-0">
|
|
See full plan comparison →
|
|
</Link>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
|
{PLANS.map((plan) => (
|
|
<Card key={plan.id} padding="lg" highlighted={plan.highlighted}>
|
|
{plan.highlighted && <Badge tone="brand" className="mb-3">Most popular</Badge>}
|
|
<h3 className="text-white font-semibold">{plan.name}</h3>
|
|
<p className="text-gray-500 text-xs mt-1">{plan.tagline}</p>
|
|
<p className="text-white text-2xl font-bold font-mono mt-4">
|
|
{plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
|
|
{plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && <span className="text-gray-500 text-sm font-normal">/mo</span>}
|
|
</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Final CTA */}
|
|
<section className="border-t border-gray-800">
|
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
|
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
|
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
|
|
Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
|
|
</p>
|
|
<div className="mt-8">
|
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|