diff --git a/drb-frontend/app/dashboard/page.tsx b/drb-frontend/app/dashboard/page.tsx index 6201c9d..5bee5d2 100644 --- a/drb-frontend/app/dashboard/page.tsx +++ b/drb-frontend/app/dashboard/page.tsx @@ -1,21 +1,52 @@ "use client"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes"; import { useCalls, useActiveCalls } from "@/lib/useCalls"; import { useSystems } from "@/lib/useSystems"; +import { useActiveIncidents } from "@/lib/useIncidents"; import { NodeCard } from "@/components/NodeCard"; import { CallRow } from "@/components/CallRow"; import { NodeConfigModal } from "@/components/NodeConfigModal"; +import { TypeBadge } from "@/components/IncidentBadges"; +import { severityBadge, severityRank } from "@/lib/severity"; import { useState } from "react"; -import type { NodeRecord } from "@/lib/types"; +import type { NodeRecord, IncidentRecord } from "@/lib/types"; import { useAuth } from "@/components/AuthProvider"; +import { PageHeader } from "@/components/ui/PageHeader"; +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"; function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { return ( -
+

{label}

{value}

-
+ + ); +} + +function fmtTime(iso: string) { + try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } + catch { return iso; } +} + +function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) { + const router = useRouter(); + return ( + router.push(`/incidents/${incident.incident_id}`)}> +
+ + {severityBadge(incident.severity)} +
+

{incident.title ?? "Untitled incident"}

+

+ {fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""} +

+
); } @@ -25,6 +56,7 @@ export default function DashboardPage() { const { calls, error: callsError } = useCalls(20); const activeCalls = useActiveCalls(); const { systems, error: systemsError } = useSystems(); + const activeIncidents = useActiveIncidents(); const [configNode, setConfigNode] = useState(null); const { isAdmin } = useAuth(); @@ -33,44 +65,66 @@ export default function DashboardPage() { const fsError = nodesError ?? callsError ?? systemsError; - return ( -
-

Dashboard

+ // Worst-first: the incident that most needs a human's attention leads the panel. + const sortedIncidents = [...activeIncidents].sort( + (a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at) + ); + const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length; - {fsError && ( -
-

Firestore error: {fsError}

-
- )} + return ( +
+ 0 && {notableIncidentCount} moderate+ active} + /> + + {fsError && } {/* Pending config banner */} {pending.length > 0 && ( -
+

{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.

- +
)} {/* Stats */}
+ 0 ? "text-orange-400" : undefined} /> 0 ? "text-orange-400" : undefined} /> -
+ {/* Active incidents — the primary "what's happening" view */} +
+
+

Active Incidents

+ + View all → + +
+ {sortedIncidents.length === 0 ? ( + + ) : ( +
+ {sortedIncidents.slice(0, 6).map((inc) => ( + + ))} +
+ )} +
+ {/* Nodes */}

Nodes

{nodes.length === 0 ? ( -

No nodes registered yet.

+ ) : (
{nodes.map((n) => ( @@ -84,9 +138,9 @@ export default function DashboardPage() {

Recent Calls

{calls.length === 0 ? ( -

No calls recorded yet.

+ ) : ( -
+ @@ -104,16 +158,12 @@ export default function DashboardPage() { ))}
-
+ )}
{configNode && ( - setConfigNode(null)} - /> + setConfigNode(null)} /> )}
); diff --git a/drb-frontend/app/faq/page.tsx b/drb-frontend/app/faq/page.tsx new file mode 100644 index 0000000..f6eb4a0 --- /dev/null +++ b/drb-frontend/app/faq/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/Badge"; +import { LinkButton } from "@/components/ui/Button"; + +const FAQS: { q: string; a: string }[] = [ + { + q: "What hardware do I need to run a node?", + a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.", + }, + { + q: "What's the difference between a 'call' and an 'incident'?", + a: "A call is a single radio transmission. An incident is the thing you actually care about — a pursuit, a fire, an accident — built by correlating related calls together, sometimes across multiple talkgroups or nodes. Incidents are the primary view; calls are the evidence behind them.", + }, + { + q: "Does DRB do the transcription and AI work itself, or is that a separate cost?", + a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.", + }, + { + q: "Can I listen to live radio traffic without opening the dashboard?", + a: "Yes — the Discord bot can join a voice channel and relay live audio from any of your nodes, so your team can listen without a separate scanner app.", + }, + { + q: "How does node ownership and team access work?", + a: "Admins have full access. Operators are scoped to a specific list of nodes they own — they see and manage only those. Viewers get read-only access to everything the org exposes. You manage all of this from Settings → Members.", + }, + { + q: "What happens if I go over my plan's node or seat limit?", + a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.", + }, + { + q: "How long is call and incident history kept?", + a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.", + }, + { + q: "Is DMR supported?", + a: "Not yet — DMR is on the roadmap but the current release only decodes P25 and analog trunked systems.", + }, +]; + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +export default function FaqPage() { + const [openIndex, setOpenIndex] = useState(0); + + return ( +
+
+ FAQ +

Frequently asked questions

+

Can't find what you're looking for? Sign in and reach out from your account.

+
+ +
+ {FAQS.map((item, i) => { + const open = openIndex === i; + return ( +
+ + {open && ( +

{item.a}

+ )} +
+ ); + })} +
+ +
+ Get started +
+
+ ); +} diff --git a/drb-frontend/app/features/page.tsx b/drb-frontend/app/features/page.tsx new file mode 100644 index 0000000..446fd11 --- /dev/null +++ b/drb-frontend/app/features/page.tsx @@ -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 ( +
+
+ Features +

Everything between the radio and the map

+

+ 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. +

+
+ +
+ {SECTIONS.map((s) => ( +
+
+

{s.eyebrow}

+

{s.title}

+

{s.body}

+
+ +
    + {s.points.map((p) => ( +
  • + + {p} +
  • + ))} +
+
+
+ ))} +
+ +
+

See it running on your own traffic

+
+ Get started +
+
+
+ ); +} diff --git a/drb-frontend/app/globals.css b/drb-frontend/app/globals.css index e9ae644..b0e35ae 100644 --- a/drb-frontend/app/globals.css +++ b/drb-frontend/app/globals.css @@ -115,3 +115,36 @@ html:not(.dark) input::placeholder, html:not(.dark) textarea::placeholder { color: #94a3b8; } + +/* ── Marketing/product surface additions (2026-08 overhaul) ───────────────── + * Same pattern as above: components use hardcoded dark-palette Tailwind + * classes, remapped here for light mode instead of dark: prefixes. + * Only new classes introduced by the marketing pages / settings shell live + * below — everything else reuses the palette already mapped above. + */ + +/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */ +html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; } +html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; } +html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; } +html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; } +html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; } +html:not(.dark) .bg-yellow-600\/10 { background-color: rgba(202,138,4,0.08) !important; } +html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !important; } + +/* Marketing hero background — subtle radial glow, brand-neutral in both themes */ +.marketing-hero-bg { + background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%); +} +html:not(.dark) .marketing-hero-bg { + background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%); +} + +/* Skeleton loading shimmer */ +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} +.skeleton { + animation: skeleton-pulse 1.6s ease-in-out infinite; +} diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx index 130de19..011541a 100644 --- a/drb-frontend/app/incidents/[id]/page.tsx +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -10,26 +10,11 @@ import { useAuth } from "@/components/AuthProvider"; import { CallRow } from "@/components/CallRow"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; +import { TypeBadge } from "@/components/IncidentBadges"; +import { severityBadge } from "@/lib/severity"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); -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", -}; - -function TypeBadge({ type }: { type: string | null }) { - const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; - return ( - - {type ?? "other"} - - ); -} - function StatusBadge({ status }: { status: IncidentRecord["status"] }) { return ( + {severityBadge(incident.severity)}

{incident.title ?? "Incident"} diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index d5aa8d3..2ea5fca 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -1,49 +1,42 @@ "use client"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { useIncidents } from "@/lib/useIncidents"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; -import { useState } from "react"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; +import { Badge } from "@/components/ui/Badge"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { SkeletonCard } from "@/components/ui/Skeleton"; +import { severityBadge, severityRank } from "@/lib/severity"; +import { TypeBadge } from "@/components/IncidentBadges"; -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", -}; +// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow). +// `severityBadge()` already returns null for the legacy "unknown" value. -const SEVERITY_COLORS: Record = { - major: "bg-red-950 text-red-400", - moderate: "bg-orange-950 text-orange-400", - minor: "bg-gray-800 text-gray-400", -}; +type SeverityFilter = "all" | "minor" | "moderate" | "major"; +const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "minor", label: "Minor+" }, + { key: "moderate", label: "Moderate+" }, + { key: "major", label: "Major only" }, +]; +const FILTER_THRESHOLD: Record = { all: -1, minor: 1, moderate: 2, major: 3 }; -function severityBadge(severity: string | null | undefined) { - if (!severity || severity === "unknown") return null; - const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400"; - return ( - - {severity} - - ); -} - -function typeBadge(type: string | null) { - const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other; - return ( - - {type ?? "other"} - - ); -} +type SortMode = "recent" | "severity"; function fmtTime(iso: string) { try { return new Date(iso).toLocaleString(); } catch { return iso; } } +// --------------------------------------------------------------------------- +// Rows / cards +// --------------------------------------------------------------------------- + function IncidentRow({ incident, isAdmin, onResolve }: { incident: IncidentRecord; isAdmin: boolean; @@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: { return ( router.push(`/incidents/${incident.incident_id}`)} > - {typeBadge(incident.type)} + {incident.title ?? "—"} - - {incident.status} - + {incident.status} {severityBadge(incident.severity)} {incident.call_ids.length} @@ -73,26 +60,98 @@ function IncidentRow({ incident, isAdmin, onResolve }: { {fmtTime(incident.updated_at)} {isAdmin && incident.status === "active" && ( - + )} ); } -function CreateModal({ onClose, onCreate }: { - onClose: () => void; - onCreate: (body: object) => Promise; +function IncidentCards({ incidents, isAdmin, onResolve }: { + incidents: IncidentRecord[]; + isAdmin: boolean; + onResolve: (id: string) => void; }) { - const [title, setTitle] = useState(""); - const [type, setType] = useState("other"); + const router = useRouter(); + return ( +
+ {incidents.map((inc) => ( + router.push(`/incidents/${inc.incident_id}`)} + > +
+
+ + {inc.status} +
+ {isAdmin && inc.status === "active" && ( + + )} +
+

{inc.title ?? "—"}

+
+ {severityBadge(inc.severity)} +

+ {fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""} +

+
+
+ ))} +
+ ); +} + +function IncidentTable({ incidents, isAdmin, onResolve }: { + incidents: IncidentRecord[]; + isAdmin: boolean; + onResolve: (id: string) => void; +}) { + return ( + <> +
+ +
+
+ + + + + + + + + + + + + + + {incidents.map((inc) => ( + + ))} + +
TypeTitleStatusSeverityCallsStartedUpdated
+
+ + ); +} + +function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise }) { + const [title, setTitle] = useState(""); + const [type, setType] = useState("other"); const [summary, setSummary] = useState(""); - const [saving, setSaving] = useState(false); + const [saving, setSaving] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: { } return ( -
-
+
+

Create Incident

@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: { />
- - + +
); } -function IncidentCards({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - const router = useRouter(); - return ( -
- {incidents.map((inc) => ( -
router.push(`/incidents/${inc.incident_id}`)} - > -
-
- {typeBadge(inc.type)} - {inc.status} -
- {isAdmin && inc.status === "active" && ( - - )} -
-

{inc.title ?? "—"}

-
- {severityBadge(inc.severity)} -

- {fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""} -

-
-
- ))} -
- ); -} - -function IncidentTable({ incidents, isAdmin, onResolve }: { - incidents: IncidentRecord[]; - isAdmin: boolean; - onResolve: (id: string) => void; -}) { - return ( - <> - {/* Mobile card view */} -
- -
- - {/* Desktop table view */} -
- - - - - - - - - - - - - - - {incidents.map((inc) => ( - - ))} - -
TypeTitleStatusSeverityCallsStartedUpdated
-
- - ); -} +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- export default function IncidentsPage() { - const { isAdmin } = useAuth(); - const { incidents, loading } = useIncidents(); + const { isAdmin } = useAuth(); + const { incidents, loading } = useIncidents(); const [showCreate, setShowCreate] = useState(false); + const [severityFilter, setSeverityFilter] = useState("all"); + const [sortMode, setSortMode] = useState("recent"); - const active = incidents.filter((i) => i.status === "active"); - const resolved = incidents.filter((i) => i.status === "resolved"); + const filtered = useMemo(() => { + const threshold = FILTER_THRESHOLD[severityFilter]; + const list = incidents.filter((i) => severityRank(i.severity) >= threshold); + if (sortMode === "severity") { + return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)); + } + return list; // useIncidents() already orders by started_at desc + }, [incidents, severityFilter, sortMode]); + + const active = filtered.filter((i) => i.status === "active"); + const resolved = filtered.filter((i) => i.status === "resolved"); + const hiddenCount = incidents.length - filtered.length; async function handleResolve(id: string) { try { await c2api.updateIncident(id, { status: "resolved" }); } @@ -253,30 +232,53 @@ export default function IncidentsPage() { } return ( -
-
-
-

Incidents

- {active.length > 0 && ( - - {active.length} active - - )} +
+ 0 && {active.length} active} + action={isAdmin && } + /> + + {/* Severity filter + sort — severity is a filter dimension, not decoration */} +
+
+ {SEVERITY_FILTERS.map(({ key, label }) => ( + + ))}
- {isAdmin && ( -
{loading ? ( -

Loading…

+
+ +
) : ( <> + {hiddenCount > 0 && ( +

+ {hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter. +

+ )} + {active.length > 0 && (

Active

@@ -291,8 +293,20 @@ export default function IncidentsPage() {
)} - {incidents.length === 0 && ( -

No incidents recorded yet.

+ {filtered.length === 0 && ( + 0 && severityFilter !== "all" ? ( + + ) : undefined + } + /> )} )} diff --git a/drb-frontend/app/layout.tsx b/drb-frontend/app/layout.tsx index 355c372..d09208c 100644 --- a/drb-frontend/app/layout.tsx +++ b/drb-frontend/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { Nav } from "@/components/Nav"; import { AuthProvider } from "@/components/AuthProvider"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { ChromeSwitcher } from "@/components/ChromeSwitcher"; import "./globals.css"; export const metadata: Metadata = { - title: "DRB Portal", - description: "Distributed Radio Bot — Control & Monitoring", + title: "DRB — Public-Safety Radio Intelligence", + description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -19,8 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) -