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:
co-authored by
Claude Opus 5
parent
4842725a03
commit
1b4ed0d09c
@@ -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're on the list</h1>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">
|
||||
Thanks — we'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't open yet. Leave your details and we'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user