1
0
Fork 0

feat(surveys): open surveys in a scrollable modal, fix page layouts

- list page and detail page use the standard site page wrapper (icon + title + subtitle)
- card click opens the survey in a scrollable shadcn Dialog; submitting removes the card on close
- detail page matches the helpdesk pattern: back link, title block, form in a card
- /surveys?open=<id> deep link auto-opens the modal; SurveyForm gained onSuccess
This commit is contained in:
Jason Fraley 2026-09-23 21:07:07 -04:00
parent b85242d242
commit d56af60366
4 changed files with 189 additions and 112 deletions

View file

@ -3,7 +3,7 @@ import { headers as nextHeaders } from "next/headers";
import { getPayload } from "payload"; import { getPayload } from "payload";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ClipboardCheck, Lock, TriangleAlert } from "lucide-react"; import { ArrowLeftIcon, ClipboardCheck, Lock, TriangleAlert } from "lucide-react";
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm"; import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@ -16,7 +16,7 @@ export const metadata = {
function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return ( return (
<Card> <Card className="max-w-2xl">
<CardContent className="flex flex-col items-center gap-3 py-12 text-center"> <CardContent className="flex flex-col items-center gap-3 py-12 text-center">
{icon} {icon}
<div> <div>
@ -64,26 +64,6 @@ export default async function SurveyDetailPage({
}), }),
]); ]);
if (submissionCount.totalDocs > 0) {
return (
<NoticeCard
icon={<ClipboardCheck className="text-emerald-400 size-10" />}
title="Already submitted"
body="You have already responded to this survey."
/>
);
}
if (!isSurveyOpen(form, Date.now())) {
return (
<NoticeCard
icon={<Lock className="text-muted-foreground size-10" />}
title="Survey closed"
body="This survey is not open right now."
/>
);
}
const heldQualifications = (profileRes.docs[0]?.progression?.qualifications ?? []) const heldQualifications = (profileRes.docs[0]?.progression?.qualifications ?? [])
.map((q) => (typeof q === "object" && q !== null && "name" in q ? (q.name as string) : typeof q === "string" ? q : null)) .map((q) => (typeof q === "object" && q !== null && "name" in q ? (q.name as string) : typeof q === "string" ? q : null))
.filter((n): n is string => n !== null); .filter((n): n is string => n !== null);
@ -98,39 +78,70 @@ export default async function SurveyDetailPage({
qualificationNames: heldQualifications, qualificationNames: heldQualifications,
}); });
if (!eligible) { let content: React.ReactNode;
return ( if (submissionCount.totalDocs > 0) {
content = (
<NoticeCard
icon={<ClipboardCheck className="text-emerald-400 size-10" />}
title="Already submitted"
body="You have already responded to this survey."
/>
);
} else if (!isSurveyOpen(form, Date.now())) {
content = (
<NoticeCard
icon={<Lock className="text-muted-foreground size-10" />}
title="Survey closed"
body="This survey is not open right now."
/>
);
} else if (!eligible) {
content = (
<NoticeCard <NoticeCard
icon={<TriangleAlert className="text-amber-400 size-10" />} icon={<TriangleAlert className="text-amber-400 size-10" />}
title="Not eligible" title="Not eligible"
body="This survey is not available to your account." body="This survey is not available to your account."
/> />
); );
} else {
content = (
<Card className="max-w-2xl">
<CardContent className="pt-6">
<SurveyForm
formId={form.id}
fields={form.fields ?? []}
submitLabel={form.submitButtonLabel || "Submit"}
confirmationMessage={form.confirmationMessage ?? null}
/>
</CardContent>
</Card>
);
} }
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="flex flex-col gap-6 p-5">
<div> <div className="flex flex-col gap-3">
<p className="text-muted-foreground text-xs uppercase tracking-wide"> <Link
<Link href="/surveys" className="hover:underline"> href="/surveys"
Surveys className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
</Link> >
{" / "} <ArrowLeftIcon className="size-3" />
Survey Surveys
</p> </Link>
<h1 className="text-2xl font-semibold">{form.title}</h1> <div className="flex flex-col gap-1">
{form.activeUntil && ( <div className="flex items-center gap-2">
<p className="text-muted-foreground text-sm"> <ClipboardCheck className="size-5 text-muted-foreground" />
Open until {new Date(form.activeUntil).toLocaleString()} <h1 className="text-lg font-semibold">{form.title}</h1>
</p> </div>
)} {form.activeUntil && (
<p className="text-sm text-muted-foreground">
Open until {new Date(form.activeUntil).toLocaleString()}
</p>
)}
</div>
</div> </div>
<SurveyForm
formId={form.id} {content}
fields={form.fields ?? []}
submitLabel={form.submitButtonLabel || "Submit"}
confirmationMessage={form.confirmationMessage ?? null}
/>
</div> </div>
); );
} }

View file

@ -3,22 +3,18 @@ import { headers as nextHeaders } from "next/headers";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { ClipboardCheck } from "lucide-react"; import { ClipboardCheck } from "lucide-react";
import { SurveyList, type SurveyListItem } from "@/components/frontend/surveys/SurveyList"; import { SurveyList, type SurveyListItem } from "@/components/frontend/surveys/SurveyList";
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; import { getPendingSurveys } from "@/lib/surveys/pending";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
export const metadata = { export const metadata = {
title: "Surveys: Polaris Task Force", title: "Surveys: Polaris Task Force",
}; };
function audienceContext(user: User) { export default async function SurveysPage({
const roleIds = (user.roleDocs ?? []) searchParams,
.map((r) => (typeof r === "object" ? r.id : r)) }: {
.filter((id): id is number => typeof id === "number"); searchParams: Promise<{ open?: string }>;
}) {
return { userId: user.id, roleIds };
}
export default async function SurveysPage() {
const headers = await nextHeaders(); const headers = await nextHeaders();
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
@ -27,47 +23,31 @@ export default async function SurveysPage() {
// the authed render path's types. // the authed render path's types.
if (!user || user.collection !== "users") return null; if (!user || user.collection !== "users") return null;
const [formsResult, profileRes] = await Promise.all([ const [pendingForms, params] = await Promise.all([getPendingSurveys(payload, user), searchParams]);
payload.find({ const openParam = Number(params.open);
collection: "forms", const initialSurveyId = Number.isInteger(openParam) ? openParam : null;
sort: "-createdAt",
limit: 100,
depth: 1,
}),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 1,
}),
]);
const heldQualifications = (profileRes.docs[0]?.progression?.qualifications ?? []) const items: SurveyListItem[] = pendingForms.map((form) => ({
.map((q) => (typeof q === "object" && q !== null && "name" in q ? (q.name as string) : typeof q === "string" ? q : null)) id: form.id,
.filter((n): n is string => n !== null); title: form.title,
openUntil: form.activeUntil ?? null,
const ctx = { ...audienceContext(user), qualificationNames: heldQualifications }; questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
const now = Date.now(); fields: form.fields ?? [],
submitLabel: form.submitButtonLabel || "Submit",
const items: SurveyListItem[] = formsResult.docs confirmationMessage: form.confirmationMessage ?? null,
.filter((form) => isSurveyOpen(form, now) && surveyAudienceMatches(form, ctx)) }));
.map((form) => ({
id: form.id,
title: form.title,
openUntil: form.activeUntil ?? null,
questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
}));
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6 p-5">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-1">
<ClipboardCheck className="text-muted-foreground size-6" /> <div className="flex items-center gap-2">
<div> <ClipboardCheck className="size-5 text-muted-foreground" />
<h1 className="text-2xl font-semibold">Surveys</h1> <h1 className="text-lg font-semibold">Surveys</h1>
<p className="text-muted-foreground text-sm">Open surveys for you to take.</p>
</div> </div>
<p className="text-sm text-muted-foreground">Open surveys for you to take.</p>
</div> </div>
<SurveyList items={items} />
<SurveyList items={items} initialSurveyId={initialSurveyId} />
</div> </div>
); );
} }

View file

@ -44,11 +44,13 @@ export function SurveyForm({
fields, fields,
submitLabel, submitLabel,
confirmationMessage, confirmationMessage,
onSuccess,
}: { }: {
formId: number; formId: number;
fields: SurveyField[]; fields: SurveyField[];
submitLabel: string; submitLabel: string;
confirmationMessage: ConfirmationMessage; confirmationMessage: ConfirmationMessage;
onSuccess?: () => void;
}) { }) {
const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields)); const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields));
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle"); const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
@ -92,6 +94,7 @@ export function SurveyForm({
throw new Error(message || `Submission failed (${response.status}).`); throw new Error(message || `Submission failed (${response.status}).`);
} }
setStatus("success"); setStatus("success");
onSuccess?.();
} catch (e) { } catch (e) {
setStatus("idle"); setStatus("idle");
setError(e instanceof Error ? e.message : "Submission failed."); setError(e instanceof Error ? e.message : "Submission failed.");

View file

@ -1,17 +1,63 @@
import Link from "next/link"; "use client";
import { ClipboardCheck, Clock, ListChecks } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import {
ChevronRight,
ClipboardCheck,
Clock,
ListChecks,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Form } from "@/payload-types";
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
export interface SurveyListItem { export interface SurveyListItem {
id: number; id: number;
title: string; title: string;
openUntil: string | null; openUntil: string | null;
questionCount: number; questionCount: number;
fields: NonNullable<Form["fields"]>;
submitLabel: string;
confirmationMessage: Form["confirmationMessage"];
} }
export function SurveyList({ items }: { items: SurveyListItem[] }) { export function SurveyList({
if (items.length === 0) { items,
initialSurveyId = null,
}: {
items: SurveyListItem[];
initialSurveyId?: number | null;
}) {
const [active, setActive] = useState<SurveyListItem | null>(
() => items.find((item) => item.id === initialSurveyId) ?? null,
);
const [succeeded, setSucceeded] = useState(false);
const [completedIds, setCompletedIds] = useState<Set<number>>(() => new Set());
const visible = items.filter((item) => !completedIds.has(item.id));
const close = () => {
if (active && succeeded) {
setCompletedIds((prev) => new Set(prev).add(active.id));
}
setSucceeded(false);
setActive(null);
};
if (visible.length === 0 && !active) {
return ( return (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center"> <div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center">
<ClipboardCheck className="size-10 opacity-50" /> <ClipboardCheck className="size-10 opacity-50" />
@ -21,14 +67,28 @@ export function SurveyList({ items }: { items: SurveyListItem[] }) {
} }
return ( return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <>
{items.map((item) => ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link key={item.id} href={`/surveys/${item.id}`} className="group"> {visible.map((item) => (
<Card className="group-hover:border-sidebar-border group-hover:bg-sidebar-accent h-full transition-colors"> <Card
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
setSucceeded(false);
setActive(item);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSucceeded(false);
setActive(item);
}
}}
className="hover:border-sidebar-border hover:bg-sidebar-accent/40 h-full cursor-pointer transition-colors"
>
<CardHeader> <CardHeader>
<CardTitle className="group-hover:text-foreground text-foreground/90 text-base"> <CardTitle className="text-base">{item.title}</CardTitle>
{item.title}
</CardTitle>
<CardDescription className="flex items-center gap-3 text-xs"> <CardDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<ListChecks className="size-3.5" /> <ListChecks className="size-3.5" />
@ -37,19 +97,42 @@ export function SurveyList({ items }: { items: SurveyListItem[] }) {
{item.openUntil && ( {item.openUntil && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="size-3.5" /> <Clock className="size-3.5" />
until {new Date(item.openUntil).toLocaleDateString()} closes {new Date(item.openUntil).toLocaleDateString()}
</span> </span>
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="text-muted-foreground flex items-center gap-1 text-sm">
<Badge variant="secondary" className="text-[10px] uppercase"> Take survey
Open <ChevronRight className="size-4" />
</Badge>
</CardContent> </CardContent>
</Card> </Card>
</Link> ))}
))} </div>
</div>
<Dialog open={active !== null} onOpenChange={(open) => !open && close()}>
{active && (
<DialogContent className="max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{active.title}</DialogTitle>
{active.openUntil && (
<DialogDescription>
Open until {new Date(active.openUntil).toLocaleString()}
</DialogDescription>
)}
</DialogHeader>
<div className="max-h-[calc(85vh-6rem)] overflow-y-auto px-6 py-6">
<SurveyForm
formId={active.id}
fields={active.fields}
submitLabel={active.submitLabel}
confirmationMessage={active.confirmationMessage}
onSuccess={() => setSucceeded(true)}
/>
</div>
</DialogContent>
)}
</Dialog>
</>
); );
} }