feat(intake): picker page with single-project redirect + zero-projects fallback

Phase 9b Task 4. The /intake/[token] route is now the picker.
Single-project links redirect straight to the wizard (no extra
click). Zero-projects links show a static 'nothing here yet' page
with no info leakage. Phase 8 backwards compat: a Phase 8 link
(with link.project set, link.client empty) redirects into the
wizard URL using that projectId, so the Phase 8 UX is unchanged.
This commit is contained in:
Dave 2026-09-21 13:32:31 +02:00
parent 78c3b9e401
commit b1b982ee34

View file

@ -1,20 +1,27 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getAdminPb } from "@/lib/pb/admin";
import { IntakePublic } from "@/components/intake/intake-public";
import { resolveIntakeLink } from "@/lib/intake-links";
import type { ProjectRecord } from "@/lib/pb/types";
import { listPickerProjects } from "@/lib/intake-links/client-projects";
/**
* The unauthenticated intake page.
* The picker — the new entry point for a Phase 9 client-bound link.
*
* Resolves the token server-side. Unknown, revoked and expired tokens all
* 404 — same wording so the response cannot be used to probe existence. The
* link's label is shown to the submitter; nothing else from `intake_links`
* crosses into the rendered HTML (notes, counts and the token itself are
* owner-only).
* Resolves the token server-side. Unknown / revoked / expired → 404
* (same wording as the wizard page, so a hostile body cannot probe).
*
* Three branches after resolution:
* - zero picker-eligible projects → static "nothing here yet" page
* - one → server-side redirect to the wizard URL with the projectId
* - two or more → render a list of project names; each item links to
* /intake/[token]/p/[projectId]
*
* Phase 8 backwards compat: if the link has `link.project` set and
* `link.client` empty, the page behaves like the Phase 8 wizard URL —
* redirect to `/intake/[token]/p/[projectId]` using that single project.
* This means every Phase 8 URL keeps working with zero changes.
*/
export default async function IntakePage({
export default async function IntakePickerPage({
params,
}: {
params: Promise<{ token: string }>;
@ -23,34 +30,59 @@ export default async function IntakePage({
const link = await resolveIntakeLink(token);
if (!link || !link.active) notFound();
if (link.expires_at && link.expires_at < new Date().toISOString().slice(0, 10)) {
if (
link.expires_at &&
link.expires_at < new Date().toISOString().slice(0, 10)
) {
notFound();
}
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(link.project)
.catch(() => null);
// Phase 8 backwards compat: per-project link → wizard URL with that projectId.
if (link.project && !link.client) {
redirect(`/intake/${token}/p/${link.project}`);
}
if (!project) notFound();
if (!link.client) notFound();
const projects = await listPickerProjects(link.client);
if (projects.length === 0) {
return (
<main className="mx-auto max-w-xl space-y-3 p-6">
<h1 className="text-lg font-semibold text-text-primary">
Nothing here yet
</h1>
<p className="text-sm text-text-secondary">
The developer hasn&apos;t enabled any projects for this page yet.
Try again later, or contact them directly.
</p>
</main>
);
}
if (projects.length === 1) {
redirect(`/intake/${token}/p/${projects[0].id}`);
}
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<header className="space-y-1">
<p className="text-xs uppercase tracking-wide text-text-muted">
Request to {project.name}
</p>
<h1 className="text-lg font-semibold text-text-primary">
{link.label}
Tell us which project you have a question about
</h1>
</header>
<IntakePublic
projectId={project.id}
projectName={project.name}
token={token}
/>
<ul className="space-y-2">
{projects.map((p) => (
<li key={p.id}>
<Link
href={`/intake/${token}/p/${p.id}`}
className="block rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4 text-sm text-text-primary hover:bg-surface-raised"
>
{p.name}
</Link>
</li>
))}
</ul>
</main>
);
}