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:
parent
b85242d242
commit
d56af60366
4 changed files with 189 additions and 112 deletions
|
|
@ -3,7 +3,7 @@ import { headers as nextHeaders } from "next/headers";
|
|||
import { getPayload } from "payload";
|
||||
import Link from "next/link";
|
||||
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 { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
||||
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 }) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
{icon}
|
||||
<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 ?? [])
|
||||
.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);
|
||||
|
|
@ -98,39 +78,70 @@ export default async function SurveyDetailPage({
|
|||
qualificationNames: heldQualifications,
|
||||
});
|
||||
|
||||
if (!eligible) {
|
||||
return (
|
||||
let content: React.ReactNode;
|
||||
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
|
||||
icon={<TriangleAlert className="text-amber-400 size-10" />}
|
||||
title="Not eligible"
|
||||
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 (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<Link href="/surveys" className="hover:underline">
|
||||
Surveys
|
||||
</Link>
|
||||
{" / "}
|
||||
Survey
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{form.title}</h1>
|
||||
{form.activeUntil && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Open until {new Date(form.activeUntil).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/surveys"
|
||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3" />
|
||||
Surveys
|
||||
</Link>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">{form.title}</h1>
|
||||
</div>
|
||||
{form.activeUntil && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open until {new Date(form.activeUntil).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SurveyForm
|
||||
formId={form.id}
|
||||
fields={form.fields ?? []}
|
||||
submitLabel={form.submitButtonLabel || "Submit"}
|
||||
confirmationMessage={form.confirmationMessage ?? null}
|
||||
/>
|
||||
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,18 @@ import { headers as nextHeaders } from "next/headers";
|
|||
import { getPayload } from "payload";
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
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";
|
||||
|
||||
export const metadata = {
|
||||
title: "Surveys: Polaris Task Force",
|
||||
};
|
||||
|
||||
function audienceContext(user: User) {
|
||||
const roleIds = (user.roleDocs ?? [])
|
||||
.map((r) => (typeof r === "object" ? r.id : r))
|
||||
.filter((id): id is number => typeof id === "number");
|
||||
|
||||
return { userId: user.id, roleIds };
|
||||
}
|
||||
|
||||
export default async function SurveysPage() {
|
||||
export default async function SurveysPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ open?: string }>;
|
||||
}) {
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
|
@ -27,47 +23,31 @@ export default async function SurveysPage() {
|
|||
// the authed render path's types.
|
||||
if (!user || user.collection !== "users") return null;
|
||||
|
||||
const [formsResult, profileRes] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "forms",
|
||||
sort: "-createdAt",
|
||||
limit: 100,
|
||||
depth: 1,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: user.id } },
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
}),
|
||||
]);
|
||||
const [pendingForms, params] = await Promise.all([getPendingSurveys(payload, user), searchParams]);
|
||||
const openParam = Number(params.open);
|
||||
const initialSurveyId = Number.isInteger(openParam) ? openParam : null;
|
||||
|
||||
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))
|
||||
.filter((n): n is string => n !== null);
|
||||
|
||||
const ctx = { ...audienceContext(user), qualificationNames: heldQualifications };
|
||||
const now = Date.now();
|
||||
|
||||
const items: SurveyListItem[] = formsResult.docs
|
||||
.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,
|
||||
}));
|
||||
const items: SurveyListItem[] = pendingForms.map((form) => ({
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
openUntil: form.activeUntil ?? null,
|
||||
questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
|
||||
fields: form.fields ?? [],
|
||||
submitLabel: form.submitButtonLabel || "Submit",
|
||||
confirmationMessage: form.confirmationMessage ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="text-muted-foreground size-6" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Surveys</h1>
|
||||
<p className="text-muted-foreground text-sm">Open surveys for you to take.</p>
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Surveys</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Open surveys for you to take.</p>
|
||||
</div>
|
||||
<SurveyList items={items} />
|
||||
|
||||
<SurveyList items={items} initialSurveyId={initialSurveyId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,11 +44,13 @@ export function SurveyForm({
|
|||
fields,
|
||||
submitLabel,
|
||||
confirmationMessage,
|
||||
onSuccess,
|
||||
}: {
|
||||
formId: number;
|
||||
fields: SurveyField[];
|
||||
submitLabel: string;
|
||||
confirmationMessage: ConfirmationMessage;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields));
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
|
||||
|
|
@ -92,6 +94,7 @@ export function SurveyForm({
|
|||
throw new Error(message || `Submission failed (${response.status}).`);
|
||||
}
|
||||
setStatus("success");
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
setStatus("idle");
|
||||
setError(e instanceof Error ? e.message : "Submission failed.");
|
||||
|
|
|
|||
|
|
@ -1,17 +1,63 @@
|
|||
import Link from "next/link";
|
||||
import { ClipboardCheck, Clock, ListChecks } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
id: number;
|
||||
title: string;
|
||||
openUntil: string | null;
|
||||
questionCount: number;
|
||||
fields: NonNullable<Form["fields"]>;
|
||||
submitLabel: string;
|
||||
confirmationMessage: Form["confirmationMessage"];
|
||||
}
|
||||
|
||||
export function SurveyList({ items }: { items: SurveyListItem[] }) {
|
||||
if (items.length === 0) {
|
||||
export function SurveyList({
|
||||
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 (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center">
|
||||
<ClipboardCheck className="size-10 opacity-50" />
|
||||
|
|
@ -21,14 +67,28 @@ export function SurveyList({ items }: { items: SurveyListItem[] }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Link key={item.id} href={`/surveys/${item.id}`} className="group">
|
||||
<Card className="group-hover:border-sidebar-border group-hover:bg-sidebar-accent h-full transition-colors">
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((item) => (
|
||||
<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>
|
||||
<CardTitle className="group-hover:text-foreground text-foreground/90 text-base">
|
||||
{item.title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<ListChecks className="size-3.5" />
|
||||
|
|
@ -37,19 +97,42 @@ export function SurveyList({ items }: { items: SurveyListItem[] }) {
|
|||
{item.openUntil && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3.5" />
|
||||
until {new Date(item.openUntil).toLocaleDateString()}
|
||||
closes {new Date(item.openUntil).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||
Open
|
||||
</Badge>
|
||||
<CardContent className="text-muted-foreground flex items-center gap-1 text-sm">
|
||||
Take survey
|
||||
<ChevronRight className="size-4" />
|
||||
</CardContent>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue