Ship /terms, /privacy, and /waitlist as structure, not finished pages

SAAS_PLAN.md B5/B6, narrowed: no Stripe/pricing/tier work of any kind this
pass (a mid-build correction from the business side landed while this was
in progress - the commercial model, SAAS_PLAN.md section 6.1, is still
undecided), so app/pricing and lib/billing.ts's PLANS are untouched here.
What's left of B5/B6 without that - real legal pages and a working
waitlist - still ships.

app/terms/page.tsx and app/privacy/page.tsx are section scaffolding, not
legal text. Every section is a TODO(legal) note describing what that
section needs to cover, and the page leads with a "Draft - not yet in
force" banner. This isn't caution for its own sake: DRB records, stores,
and transcribes public-safety radio traffic, and recording/rebroadcast
legality varies by state (SAAS_PLAN.md section 6.3) - an agent-generated
draft here would be actively wrong to publish, not just unpolished. Both
were pre-added to middleware.ts's PUBLIC_PATHS and ChromeSwitcher's
MARKETING_PATHS two commits ago; MarketingFooter now links both.

app/waitlist/page.tsx is a real, working form against the already-shipped
POST /waitlist - email + optional org name/note, no plan or price
mentioned anywhere on it, matching the backend route's own scope (rate
limited by source IP, not coupled to any tier). Linked from
MarketingFooter as "Request access," not from the pricing page - pricing
CTAs stay exactly as they were.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-18 20:38:49 -04:00
co-authored by Claude Opus 5
parent 4842725a03
commit 1b4ed0d09c
5 changed files with 281 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only — see app/terms/page.tsx for why the
* agent building this did not write real legal text. Privacy Policy needs
* the same jurisdiction-aware legal review as Terms, plus specifics this
* agent cannot respond for on the owner's behalf: what a real DPA/CCPA/GDPR
* posture looks like, and what third-party processors (OpenAI, Gemini,
* Google Maps, Firebase/GCP, Stripe once chosen) actually receive and why.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. What data this collects",
note: "TODO(legal): account data (email, org membership), field node telemetry (location, status), radio call audio and AI-generated transcripts/entities/incident data, and usage/session logs (drb-c2-core's audit_log and user_sessions collections already exist and hold some of this today).",
},
{
heading: "2. Third parties this data is sent to, and why",
note: "TODO(legal): OpenAI (Whisper transcription), Google Gemini (incident extraction/summarization/embeddings), Google Maps (geocoding location strings extracted from transcripts), Google Cloud (Firestore + GCS storage, Firebase Auth), and — once a payment processor is chosen (SAAS_PLAN.md section 6.5, not yet decided) — that processor. Each of these is a real, already-integrated dependency, not a hypothetical one; this section needs to name them accurately, not generically.",
},
{
heading: "3. Recorded radio traffic specifically",
note: "TODO(legal): this product's core function is recording, transcribing, and storing monitored radio audio — including public-safety traffic that may name individuals, locations, and in-progress incidents. This needs explicit treatment distinct from generic 'we collect usage data' privacy boilerplate, and needs to be read alongside the same legal review flagged in Terms section 3.",
},
{
heading: "4. How long data is kept",
note: "TODO(legal): no retention enforcement exists in the product yet (no TTL, no sweep, no deletion job — see DEFERRED.md) — this section cannot promise a retention/deletion window the system doesn't actually implement.",
},
{
heading: "5. Customer and end-user rights",
note: "TODO(legal): access/export/deletion requests, and who they're directed to — org owner vs. platform operator.",
},
{
heading: "6. Cookies and session data",
note: "TODO(legal): drb_session is a client-set, non-httpOnly cookie used only for UI redirect logic (not an auth boundary — see CLAUDE.md); Firebase Auth sets its own session storage. No analytics/tracking cookies are set today.",
},
{
heading: "7. Security practices",
note: "TODO(legal): at a level appropriate for public disclosure — Firestore security rules, per-node credentials, encrypted transport. Should be reviewed against SAAS_PLAN.md's actual findings before publishing any specific claim.",
},
{
heading: "8. Changes to this policy",
note: "TODO(legal): how customers are notified.",
},
{
heading: "9. Contact",
note: "TODO(legal): real company legal identity and contact address — not yet decided (SAAS_PLAN.md section 6.6).",
},
];
export default function PrivacyPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Privacy Policy. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page
describes a binding commitment about how data is handled.
</p>
</div>
<h1 className="text-display-sm text-white">Privacy Policy</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <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 reach us directly</Link>.
</p>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
/**
* SAAS_PLAN.md B5: page structure only. The agent building this is
* explicitly instructed not to invent legal text — DRB records, stores, and
* transcribes public-safety radio traffic, and recording/rebroadcast
* legality varies by state (see SAAS_PLAN.md section 6.3), so this needs a
* human, and probably a lawyer, not a generated draft. Every section below
* is a placeholder marking what a real Terms of Service needs to cover, not
* actual terms — see the banner and every TODO(legal) marker.
*/
const SECTIONS: { heading: string; note: string }[] = [
{
heading: "1. Acceptance of terms",
note: "TODO(legal): standard acceptance clause — using the service means agreeing to these terms.",
},
{
heading: "2. What the service does and does not do",
note: "TODO(legal): describe the product (SDR ingestion, transcription, AI correlation, Discord relay) and, importantly, disclaim accuracy — AI-generated transcripts and incident summaries are not guaranteed accurate and must not be relied on as the sole source for dispatch or safety decisions.",
},
{
heading: "3. Radio recording and rebroadcast — the part that needs a lawyer",
note: "TODO(legal): this is the section that actually matters. Recording, storing, and rebroadcasting monitored radio traffic (including public-safety frequencies) has different legal treatment by state and by traffic type (encrypted vs. clear, dispatch vs. tactical). Needs jurisdiction-aware legal review before this product can be sold across state lines — do not ship this page live without it.",
},
{
heading: "4. Customer responsibilities and acceptable use",
note: "TODO(legal): who owns the hardware, who's responsible for lawful operation of the field node, prohibited uses.",
},
{
heading: "5. Data ownership and retention",
note: "TODO(legal): who owns the recorded audio/transcripts/incidents, how long they're kept, what happens on cancellation or account deletion. Note: no retention enforcement exists in the product yet either (see DEFERRED.md) — this section can't promise a retention window the system doesn't yet enforce.",
},
{
heading: "6. Payment, billing, and cancellation",
note: "TODO(legal): once a billing model and pricing exist (SAAS_PLAN.md section 6, still undecided) — refunds, proration, what happens to data on non-payment.",
},
{
heading: "7. Service availability and support",
note: "TODO(legal): whether any uptime/SLA commitment is made (today: none).",
},
{
heading: "8. Limitation of liability",
note: "TODO(legal): standard limitation-of-liability language, reviewed against the fact that this product touches public-safety-adjacent data.",
},
{
heading: "9. Changes to these terms",
note: "TODO(legal): how customers are notified of material changes.",
},
{
heading: "10. Governing law and contact",
note: "TODO(legal): governing jurisdiction, and a real company legal identity and contact address (SAAS_PLAN.md section 6.6 — not yet decided).",
},
];
export default function TermsPage() {
return (
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-10">
<p className="text-yellow-200 text-sm font-mono font-semibold">
Draft — not yet in force
</p>
<p className="text-yellow-200/80 text-xs mt-1 leading-relaxed">
This page is a structural placeholder, not a real Terms of Service. Every section below is a{" "}
<code className="text-yellow-100">TODO(legal)</code> marker, not actual legal text. Nothing on this page is
binding, and no self-serve signup should be considered subject to it until an actual attorney-reviewed
version replaces this content.
</p>
</div>
<h1 className="text-display-sm text-white">Terms of Service</h1>
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
<div className="mt-10 space-y-8">
{SECTIONS.map((s) => (
<section key={s.heading}>
<h2 className="text-white font-semibold">{s.heading}</h2>
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
</section>
))}
</div>
<p className="text-gray-600 text-xs font-mono mt-16">
Questions in the meantime? <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 reach us directly</Link>.
</p>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { c2api } from "@/lib/c2api";
/**
* SAAS_PLAN.md B6's waitlist form — POST /waitlist (routers/waitlist.py) is
* public, source-IP rate-limited, and deliberately not coupled to any plan
* or tier: the commercial model (who runs the node, what a customer
* actually buys) is still an open decision (SAAS_PLAN.md section 6.1), so
* this collects only {email, org_name, note} and makes no promise about
* price or plan.
*/
export default function WaitlistPage() {
const [email, setEmail] = useState("");
const [orgName, setOrgName] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await c2api.joinWaitlist({ email, org_name: orgName || undefined, note: note || undefined });
setDone(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not submit — try again in a moment.");
} finally {
setSubmitting(false);
}
}
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">
{done ? (
<>
<h1 className="text-white text-lg font-bold">You&apos;re on the list</h1>
<p className="text-gray-400 text-sm leading-relaxed">
Thanks — we&apos;ll reach out at the email you gave us. In the meantime, feel free to{" "}
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
</p>
</>
) : (
<>
<div>
<h1 className="text-white text-lg font-bold">Request access</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
Self-serve signup isn&apos;t open yet. Leave your details and we&apos;ll follow up to get your organization set up.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
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>
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
<input
type="text"
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>
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={2000}
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 resize-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={submitting}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{submitting ? "Submitting…" : "Request access"}
</button>
</form>
</>
)}
</div>
</div>
);
}
@@ -13,6 +13,9 @@ export function MarketingFooter() {
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link> <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="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link> <Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
<Link href="/waitlist" className="hover:text-gray-300 transition-colors">Request access</Link>
<Link href="/terms" className="hover:text-gray-300 transition-colors">Terms</Link>
<Link href="/privacy" className="hover:text-gray-300 transition-colors">Privacy</Link>
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link> <Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
</nav> </nav>
+1 -1
View File
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
// Public marketing pages — no session required. Keep this in sync with // Public marketing pages — no session required. Keep this in sync with
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page // MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
// chrome; this one decides whether to redirect at all). // chrome; this one decides whether to redirect at all).
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]); const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy", "/waitlist"]);
// /signup and /onboarding are deliberately NOT gated by the drb_session // /signup and /onboarding are deliberately NOT gated by the drb_session
// cookie here, even though they aren't "public" in the sense of not needing // cookie here, even though they aren't "public" in the sense of not needing