diff --git a/bun.lock b/bun.lock index 3986b19..1d168aa 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", "@payloadcms/payload-cloud": "3.88.0", + "@payloadcms/plugin-form-builder": "3.88.0", "@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/ui": "3.88.0", @@ -588,6 +589,8 @@ "@payloadcms/payload-cloud": ["@payloadcms/payload-cloud@3.88.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/lib-storage": "^3.614.0", "@payloadcms/email-nodemailer": "3.88.0", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "^9.0.1" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-ijGuUkJP6wtVK5ur5/HLZOr3YrtFqHLcOhAfBAvVhgS3rkopbk+l5SIIiszGeaysAvzxAJX/NKiSRkeGhL7Qqw=="], + "@payloadcms/plugin-form-builder": ["@payloadcms/plugin-form-builder@3.88.0", "", { "dependencies": { "@payloadcms/translations": "3.88.0", "@payloadcms/ui": "3.88.0", "escape-html": "^1.0.3" }, "peerDependencies": { "payload": "3.88.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } }, "sha512-C915jEHADVN9xW7fSq/bYIZig1zjfQo+t4wrgiqjOCmB81oMgyqLRB3RlL2e6y4PzBUtgss2VS40vn/tVD/61Q=="], + "@payloadcms/plugin-mcp": ["@payloadcms/plugin-mcp@3.88.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "@types/json-schema": "7.0.15", "json-schema-to-zod": "2.6.1", "mcp-handler": "^1.0.7", "zod": "^3.25.50" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-+L4SeiYFs4hBfnwmoyxChs2jAL/dcSW16YXRQ+7ua8AIYJRveNGOHtcFZgKH0t/bHE1nK/1df9Wy2EpmBnAcTw=="], "@payloadcms/richtext-lexical": ["@payloadcms/richtext-lexical@3.88.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/headless": "0.41.0", "@lexical/html": "0.41.0", "@lexical/link": "0.41.0", "@lexical/list": "0.41.0", "@lexical/mark": "0.41.0", "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", "@payloadcms/translations": "3.88.0", "@payloadcms/ui": "3.88.0", "acorn": "8.16.0", "bson-objectid": "2.0.4", "csstype": "3.1.3", "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", "lexical": "0.41.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", "qs-esm": "8.0.1", "react-error-boundary": "4.1.2", "ts-essentials": "10.0.3", "uuid": "13.0.2" }, "peerDependencies": { "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", "@payloadcms/next": "3.88.0", "payload": "3.88.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } }, "sha512-lY8oIUiggykWKB3ljMfSidxBtA8ahMq0Qqp28OuvaCNIDp0HTMIPxcHvFOQZngMqtQHhpautD6vcQR0Q9CEHHQ=="], @@ -2702,6 +2705,8 @@ "@payloadcms/payload-cloud/nodemailer": ["nodemailer@9.0.3", "", {}, "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw=="], + "@payloadcms/plugin-form-builder/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="], + "@payloadcms/plugin-mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@payloadcms/richtext-lexical/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="], diff --git a/package.json b/package.json index ba35e01..9748f8f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/next": "3.88.0", "@payloadcms/payload-cloud": "3.88.0", + "@payloadcms/plugin-form-builder": "3.88.0", "@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/ui": "3.88.0", diff --git a/src/app/(frontend)/surveys/[id]/page.tsx b/src/app/(frontend)/surveys/[id]/page.tsx new file mode 100644 index 0000000..4c208fa --- /dev/null +++ b/src/app/(frontend)/surveys/[id]/page.tsx @@ -0,0 +1,136 @@ +import config from "@payload-config"; +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 { SurveyForm } from "@/components/frontend/surveys/SurveyForm"; +import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import type { User } from "@/payload-types"; + +export const metadata = { + title: "Survey: Polaris Task Force", +}; + +function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { + return ( + + + {icon} +
+

{title}

+

{body}

+
+ +
+
+ ); +} + +export default async function SurveyDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const surveyId = Number(id); + if (!Number.isInteger(surveyId)) notFound(); + + const headers = await nextHeaders(); + const payload = await getPayload({ config }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + if (!user || user.collection !== "users") return null; + + const form = await payload + .findByID({ collection: "forms", id: surveyId, depth: 1 }) + .catch(() => null); + if (!form) notFound(); + + const [profileRes, submissionCount] = await Promise.all([ + payload.find({ + collection: "profiles", + where: { user: { equals: user.id } }, + limit: 1, + depth: 1, + }), + payload.count({ + collection: "form-submissions", + where: { and: [{ form: { equals: form.id } }, { user: { equals: user.id } }] }, + }), + ]); + + if (submissionCount.totalDocs > 0) { + return ( + } + title="Already submitted" + body="You have already responded to this survey." + /> + ); + } + + if (!isSurveyOpen(form, Date.now())) { + return ( + } + 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); + + const roleIds = (user.roleDocs ?? []) + .map((r) => (typeof r === "object" ? r.id : r)) + .filter((v): v is number => typeof v === "number"); + + const eligible = surveyAudienceMatches(form, { + userId: user.id, + roleIds, + qualificationNames: heldQualifications, + }); + + if (!eligible) { + return ( + } + title="Not eligible" + body="This survey is not available to your account." + /> + ); + } + + return ( +
+
+

+ + Surveys + + {" / "} + Survey +

+

{form.title}

+ {form.activeUntil && ( +

+ Open until {new Date(form.activeUntil).toLocaleString()} +

+ )} +
+ +
+ ); +} diff --git a/src/app/(frontend)/surveys/page.tsx b/src/app/(frontend)/surveys/page.tsx new file mode 100644 index 0000000..4f9723e --- /dev/null +++ b/src/app/(frontend)/surveys/page.tsx @@ -0,0 +1,73 @@ +import config from "@payload-config"; +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 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() { + const headers = await nextHeaders(); + const payload = await getPayload({ config }); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + // The (frontend) layout renders LandingPage for guests; this only guards + // 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 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, + })); + + return ( +
+
+ +
+

Surveys

+

Open surveys for you to take.

+
+
+ +
+ ); +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 7ea28c7..0e108f1 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,4 +1,3 @@ -import { ComposeField as ComposeField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client' import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' @@ -24,6 +23,7 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93 import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ComposeField as ComposeField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client' import { default as default_513af5ed43d616d133e530d8c94232f8 } from '@/components/admin/groups/CollapsibleGroupField' import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField' import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner' @@ -46,7 +46,6 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f /** @type import('payload').ImportMap */ export const importMap = { - "@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, @@ -72,6 +71,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73, "@/components/admin/groups/CollapsibleGroupField#default": default_513af5ed43d616d133e530d8c94232f8, "@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382, "@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d, diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index 1010fa6..51f2d86 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -175,6 +175,7 @@ export function AppSidebar({ )} + diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index 9c98747..23b91ca 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -6,6 +6,7 @@ import { Boxes, Building2, ClipboardList, + ClipboardCheck, Car, Contact, Crosshair, @@ -191,5 +192,12 @@ export const sidebarData = { icon: ScrollText, }, ], + navSurveys: [ + { + title: "Surveys", + url: "/surveys", + icon: ClipboardCheck, + }, + ], navSecondary: [], }; diff --git a/src/components/frontend/surveys/SurveyForm.tsx b/src/components/frontend/surveys/SurveyForm.tsx new file mode 100644 index 0000000..9f75af4 --- /dev/null +++ b/src/components/frontend/surveys/SurveyForm.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState } from "react"; +import { RichText } from "@payloadcms/richtext-lexical/react"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { Form as FormDoc } from "@/payload-types"; + +type SurveyField = NonNullable[number]; +type ConfirmationMessage = FormDoc["confirmationMessage"]; + +type FieldValue = string | number | boolean; + +function initialValues(fields: SurveyField[]): Record { + const values: Record = {}; + for (const field of fields) { + if (field.blockType === "message" || !field.name) continue; + if (field.blockType === "checkbox" && !("defaultValue" in field)) { + values[field.name] = false; + } else if ("defaultValue" in field && field.defaultValue !== null && field.defaultValue !== undefined) { + values[field.name] = field.defaultValue; + } else { + values[field.name] = ""; + } + } + return values; +} + +export function SurveyForm({ + formId, + fields, + submitLabel, + confirmationMessage, +}: { + formId: number; + fields: SurveyField[]; + submitLabel: string; + confirmationMessage: ConfirmationMessage; +}) { + const [values, setValues] = useState>(() => initialValues(fields)); + const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle"); + const [error, setError] = useState(null); + + const setValue = (name: string, value: FieldValue) => { + setValues((prev) => ({ ...prev, [name]: value })); + }; + + const missingRequired = fields.some( + (field) => + field.blockType !== "message" && + field.required && + field.name && + (values[field.name] === "" || values[field.name] === false), + ); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + if (missingRequired) { + setError("Please answer all required questions."); + return; + } + + setStatus("submitting"); + try { + const response = await fetch("/api/form-submissions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + form: formId, + submissionData: Object.entries(values).map(([field, value]) => ({ field, value })), + }), + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { errors?: { message?: string }[] } | null; + const message = body?.errors?.[0]?.message; + throw new Error(message || `Submission failed (${response.status}).`); + } + setStatus("success"); + } catch (e) { + setStatus("idle"); + setError(e instanceof Error ? e.message : "Submission failed."); + } + }; + + if (status === "success") { + return ( + + + {confirmationMessage ? ( +
+ +
+ ) : ( +

Your response has been recorded. Thank you!

+ )} +
+
+ ); + } + + return ( +
+ {fields.map((field, index) => { + if (field.blockType === "message") { + return field.message ? ( +
+ +
+ ) : null; + } + + const name = field.name; + const label = field.label || name; + const value = values[name]; + + return ( +
+ {field.blockType === "checkbox" ? ( +
+ setValue(name, checked === true)} + /> + +
+ ) : ( + + )} + + {(field.blockType === "text" || field.blockType === "email" || field.blockType === "country" || field.blockType === "state") && ( + setValue(name, e.target.value)} + /> + )} + + {field.blockType === "textarea" && ( +