From b1b982ee3401bc57841bcf3ae059c38f02f19f67 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:32:31 +0200 Subject: [PATCH] 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. --- src/app/intake/[token]/page.tsx | 88 ++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/src/app/intake/[token]/page.tsx b/src/app/intake/[token]/page.tsx index d1e9034..d0f44d7 100644 --- a/src/app/intake/[token]/page.tsx +++ b/src/app/intake/[token]/page.tsx @@ -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(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 ( +
+

+ Nothing here yet +

+

+ The developer hasn't enabled any projects for this page yet. + Try again later, or contact them directly. +

+
+ ); + } + + if (projects.length === 1) { + redirect(`/intake/${token}/p/${projects[0].id}`); + } return (
-

- Request to {project.name} -

- {link.label} + Tell us which project you have a question about

- +
    + {projects.map((p) => ( +
  • + + {p.name} + +
  • + ))} +
); }