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:
parent
78c3b9e401
commit
b1b982ee34
1 changed files with 60 additions and 28 deletions
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue