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:
@@ -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 (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
||||
<Card>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
{severityBadge(incident.severity)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
|
||||
<p className="text-gray-500 text-xs font-mono mt-2">
|
||||
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NodeRecord | null>(null);
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
@@ -33,44 +65,66 @@ export default function DashboardPage() {
|
||||
|
||||
const fsError = nodesError ?? callsError ?? systemsError;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
|
||||
// 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 && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
|
||||
/>
|
||||
|
||||
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
|
||||
|
||||
{/* Pending config banner */}
|
||||
{pending.length > 0 && (
|
||||
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-indigo-300 text-sm font-mono">
|
||||
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfigNode(pending[0])}
|
||||
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
Configure now
|
||||
</button>
|
||||
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
||||
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Total Nodes" value={nodes.length} />
|
||||
<StatCard label="Systems" value={systems.length} />
|
||||
</div>
|
||||
|
||||
{/* Active incidents — the primary "what's happening" view */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
|
||||
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{sortedIncidents.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active incidents"
|
||||
description="Incidents appear here automatically as calls correlate into events."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedIncidents.slice(0, 6).map((inc) => (
|
||||
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Nodes */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
|
||||
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((n) => (
|
||||
@@ -84,9 +138,9 @@ export default function DashboardPage() {
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
<EmptyState title="No calls recorded yet" />
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<Card padding="none" className="overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -104,16 +158,12 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{configNode && (
|
||||
<NodeConfigModal
|
||||
node={configNode}
|
||||
systems={systems}
|
||||
onClose={() => setConfigNode(null)}
|
||||
/>
|
||||
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FaqPage() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="text-center">
|
||||
<Badge tone="brand">FAQ</Badge>
|
||||
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
||||
<p className="text-gray-400 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
|
||||
{FAQS.map((item, i) => {
|
||||
const open = openIndex === i;
|
||||
return (
|
||||
<div key={item.q}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(open ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="text-white font-semibold text-sm md:text-base">{item.q}</span>
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
{open && (
|
||||
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
||||
@@ -93,6 +78,7 @@ export default function IncidentDetailPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
<StatusBadge status={incident.status} />
|
||||
{severityBadge(incident.severity)}
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||
{incident.title ?? "Incident"}
|
||||
|
||||
+179
-165
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<SeverityFilter, number> = { 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 (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeBadge(type: string | null) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<tr
|
||||
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
||||
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
||||
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
incident.status === "active"
|
||||
? "bg-green-900 text-green-300"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}>
|
||||
{incident.status}
|
||||
</span>
|
||||
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||
@@ -73,22 +60,94 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isAdmin && incident.status === "active" && (
|
||||
<button
|
||||
<Button
|
||||
size="sm" variant="secondary"
|
||||
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ onClose, onCreate }: {
|
||||
onClose: () => void;
|
||||
onCreate: (body: object) => Promise<void>;
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<Card
|
||||
key={inc.incident_id}
|
||||
padding="sm"
|
||||
hover
|
||||
className="cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeBadge type={inc.type} />
|
||||
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState("other");
|
||||
const [summary, setSummary] = useState("");
|
||||
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-bold">Create Incident</h2>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit" disabled={saving}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
|
||||
>
|
||||
{saving ? "Creating…" : "Create"}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<div
|
||||
key={inc.incident_id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeBadge(inc.type)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
||||
}`}>{inc.status}</span>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile card view */}
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow
|
||||
key={inc.incident_id}
|
||||
incident={inc}
|
||||
isAdmin={isAdmin}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { incidents, loading } = useIncidents();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("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 (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||
{active.length > 0 && (
|
||||
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
||||
{active.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Incidents"
|
||||
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
|
||||
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||
/>
|
||||
|
||||
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||||
key={key}
|
||||
onClick={() => setSeverityFilter(key)}
|
||||
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
||||
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
+ Create Incident
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
|
||||
Sort
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="severity">Highest severity</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||
@@ -291,8 +293,20 @@ export default function IncidentsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{incidents.length === 0 && (
|
||||
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
|
||||
{filtered.length === 0 && (
|
||||
<EmptyState
|
||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
||||
description={
|
||||
incidents.length === 0
|
||||
? "Incidents appear automatically once calls start correlating."
|
||||
: "Try a lower severity threshold."
|
||||
}
|
||||
action={
|
||||
incidents.length > 0 && severityFilter !== "all" ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 })
|
||||
<body className="min-h-screen bg-gray-950">
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
<ChromeSwitcher>{children}</ChromeSwitcher>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
@@ -44,8 +45,12 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto pt-16">
|
||||
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||
DRB
|
||||
</Link>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
|
||||
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
|
||||
+139
-3
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="text-green-400 shrink-0 mt-0.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const [interval, setInterval] = useState<BillingInterval>("monthly");
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="text-center max-w-2xl mx-auto">
|
||||
<h1 className="text-display-sm md:text-display text-white">Simple, node-based pricing</h1>
|
||||
<p className="text-gray-400 mt-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center justify-center gap-1 mt-10 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit mx-auto">
|
||||
{(["monthly", "annual"] as BillingInterval[]).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => 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" && <span className="ml-1.5 text-green-400 text-xs">save ~17%</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10 items-stretch">
|
||||
{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 (
|
||||
<Card key={plan.id} padding="lg" highlighted={plan.highlighted} className="flex flex-col">
|
||||
{plan.highlighted && <Badge tone="brand" className="mb-3 w-fit">Most popular</Badge>}
|
||||
<h2 className="text-white text-lg font-bold">{plan.name}</h2>
|
||||
<p className="text-gray-500 text-sm mt-1.5 leading-relaxed">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<span className="text-white text-3xl font-bold font-mono">{priceLabel}</span>
|
||||
{price !== null && price > 0 && <span className="text-gray-500 text-sm">/mo</span>}
|
||||
{interval === "annual" && price !== null && price > 0 && (
|
||||
<p className="text-gray-600 text-xs mt-1">billed ${plan.priceAnnualUsd}/year</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LinkButton href="/login" variant={plan.highlighted ? "primary" : "secondary"} fullWidth>
|
||||
{plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-2.5 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<CheckIcon />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-xs font-mono mt-8">
|
||||
Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
|
||||
</p>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<p className="text-gray-400">
|
||||
Questions about a plan?{" "}
|
||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||
{" "}or{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to talk to us</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||
<h2 className="text-white font-semibold">Key created</h2>
|
||||
<p className="text-xs text-gray-400">
|
||||
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
||||
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||
</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||
<input
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Ops dashboard integration"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiKeysSettingsPage() {
|
||||
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
||||
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
||||
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
||||
end. See that file for the exact backend routes a real integration needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||
</div>
|
||||
|
||||
{!loading && keys.length === 0 ? (
|
||||
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Label</th>
|
||||
<th className="px-4 py-3 text-left">Key</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3 text-white">{k.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!k.revoked && (
|
||||
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-indigo-300">
|
||||
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
|
||||
</p>
|
||||
<Badge tone="brand">Trial</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "past_due") {
|
||||
return (
|
||||
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-red-400">
|
||||
Payment failed on your last invoice. Update your payment method to avoid losing access.
|
||||
</p>
|
||||
<Badge tone="danger">Past due</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "canceled") {
|
||||
return (
|
||||
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
|
||||
<Badge tone="warning">Canceled</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
||||
<span className="text-xs font-mono text-gray-300">
|
||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
void: "neutral",
|
||||
uncollectible: "danger",
|
||||
};
|
||||
|
||||
export default function BillingSettingsPage() {
|
||||
const [sub, setSub] = useState<Subscription | null>(null);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busyPlan, setBusyPlan] = useState<PlanId | null>(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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const plan = getPlan(sub.planId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||
</p>
|
||||
|
||||
<StatusBanner sub={sub} />
|
||||
|
||||
{actionError && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Current plan"
|
||||
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
|
||||
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{PLANS.map((p) => {
|
||||
const isCurrent = p.id === sub.planId;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
||||
>
|
||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "primary"}
|
||||
disabled={isCurrent || busyPlan === p.id}
|
||||
onClick={() => handleChoosePlan(p.id)}
|
||||
fullWidth
|
||||
>
|
||||
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Invoice history" />
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Organization profile, team access, node ownership, API keys, and billing."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserRole, "brand" | "success" | "neutral"> = {
|
||||
admin: "brand",
|
||||
operator: "success",
|
||||
viewer: "neutral",
|
||||
};
|
||||
|
||||
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||
|
||||
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("viewer");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-semibold">Member invited</h2>
|
||||
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||
</div>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={(e) => setEmail(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"
|
||||
placeholder="teammate@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||
<select
|
||||
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
||||
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"
|
||||
>
|
||||
<option value="admin">Admin — full access</option>
|
||||
<option value="operator">Operator — owns nodes</option>
|
||||
<option value="viewer">Viewer — read-only</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MembersSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [savingUid, setSavingUid] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
{showInvite && (
|
||||
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!loading && users.length === 0 ? (
|
||||
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Member</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
||||
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading
|
||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||
: users.map((u) => (
|
||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-white">{u.display_name || u.email}</p>
|
||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.uid === user?.uid ? (
|
||||
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
|
||||
) : (
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={savingUid === u.uid}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
||||
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<UserRecord[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingNodeId, setSavingNodeId] = useState<string | null>(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<string, UserRecord>();
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Node</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
||||
<th className="px-4 py-3 text-left">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||
) : nodes.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
||||
) : (
|
||||
nodes.map((n) => {
|
||||
const owner = ownerByNode.get(n.node_id);
|
||||
return (
|
||||
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||
{n.name}
|
||||
</Link>
|
||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={owner?.uid ?? UNASSIGNED}
|
||||
disabled={savingNodeId === n.node_id}
|
||||
onChange={(e) => 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]"
|
||||
>
|
||||
<option value={UNASSIGNED}>Unassigned</option>
|
||||
{assignable.map((u) => (
|
||||
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
||||
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const { nodes } = useNodes();
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [sub, setSub] = useState<Subscription | null>(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 (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Organization profile"
|
||||
subtitle="Basic identity for this DRB account."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
||||
<input
|
||||
value={orgName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet">
|
||||
Save changes
|
||||
</Button>
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
Preview only — no backend endpoint stores this yet.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Overview"
|
||||
action={
|
||||
plan ? (
|
||||
<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name} plan</Badge>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
|
||||
<StatTile label="Nodes" value={nodes.length} />
|
||||
<StatTile label="Members" value={memberCount ?? "—"} />
|
||||
<StatTile
|
||||
label="Status"
|
||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
||||
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage plan & billing →
|
||||
</Link>
|
||||
<Link href="/settings/members" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage members →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||
Delete organization
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled title="Not available in this build — contact support">
|
||||
Transfer ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SettingsIndexPage() {
|
||||
redirect("/settings/organization");
|
||||
}
|
||||
@@ -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]}
|
||||
</span>
|
||||
)}
|
||||
{/* 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") && (
|
||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<MarketingHeader />
|
||||
{children}
|
||||
<MarketingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const operatorLinks = [
|
||||
// Admin-only links
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function MarketingFooter() {
|
||||
return (
|
||||
<footer className="border-t border-gray-800 mt-24">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2 font-mono font-bold text-white">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-indigo-600 text-white text-xs">D</span>
|
||||
DRB
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm font-mono text-gray-500">
|
||||
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link>
|
||||
<Link href="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
|
||||
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
|
||||
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
|
||||
</nav>
|
||||
|
||||
<p className="text-xs font-mono text-gray-600">© {new Date().getFullYear()} DRB. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-4 flex items-center gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 shrink-0 font-mono font-bold text-white tracking-tight">
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-indigo-600 text-white text-sm">D</span>
|
||||
DRB
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6 ml-4">
|
||||
{LINKS.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-mono transition-colors ${
|
||||
pathname === href ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto hidden md:flex items-center gap-3">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md">Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="ghost" size="md">Sign in</LinkButton>
|
||||
<LinkButton href="/login" size="md">Get started</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden ml-auto text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||
{LINKS.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors ${pathname === href ? "text-white" : "text-gray-400"}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-2 flex flex-col gap-2">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md" fullWidth>Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="secondary" size="md" fullWidth>Sign in</LinkButton>
|
||||
<LinkButton href="/login" size="md" fullWidth>Get started</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
TONE_CLASSES[tone],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<Variant, string> = {
|
||||
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<Size, string> = {
|
||||
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<HTMLButtonElement> & {
|
||||
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 (
|
||||
<button className={classes(variant, size, fullWidth, className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
hover?: boolean;
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
const PADDING: Record<NonNullable<CardProps["padding"]>, 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 (
|
||||
<div
|
||||
className={[
|
||||
"bg-gray-900 border rounded-xl",
|
||||
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
|
||||
hover ? "transition-colors hover:border-gray-600" : "",
|
||||
PADDING[padding],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm">{title}</h3>
|
||||
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<p className="text-gray-600">` scattered across pages. */
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
|
||||
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
|
||||
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
|
||||
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline error banner — for API/Firestore errors surfaced within a page section. */
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-white font-mono">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-800">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton className="h-3 w-full max-w-[8rem]" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -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<ApiKeyRecord[]> {
|
||||
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<void> {
|
||||
DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
|
||||
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
|
||||
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||
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 (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
|
||||
{SEVERITY_LABEL[severity]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user