Files
server-26/drb-frontend/app/waitlist/page.tsx
Logan CusanoandClaude Opus 5 1b4ed0d09c 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>
2026-08-18 20:38:49 -04:00

106 lines
4.4 KiB
TypeScript

"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>
);
}