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}
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/components/frontend/surveys/SurveyList.tsx b/src/components/frontend/surveys/SurveyList.tsx
new file mode 100644
index 0000000..87a25b0
--- /dev/null
+++ b/src/components/frontend/surveys/SurveyList.tsx
@@ -0,0 +1,55 @@
+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";
+
+export interface SurveyListItem {
+ id: number;
+ title: string;
+ openUntil: string | null;
+ questionCount: number;
+}
+
+export function SurveyList({ items }: { items: SurveyListItem[] }) {
+ if (items.length === 0) {
+ return (
+
+
+
No surveys are open for you right now.
+
+ );
+ }
+
+ return (
+
+ {items.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+
+ {item.questionCount} question{item.questionCount === 1 ? "" : "s"}
+
+ {item.openUntil && (
+
+
+ until {new Date(item.openUntil).toLocaleDateString()}
+
+ )}
+
+
+
+
+ Open
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/lib/surveys/evaluate.ts b/src/lib/surveys/evaluate.ts
new file mode 100644
index 0000000..2e98e8c
--- /dev/null
+++ b/src/lib/surveys/evaluate.ts
@@ -0,0 +1,64 @@
+/**
+ * Pure survey eligibility helpers (client-safe: type-only payload-types import).
+ * The list/detail pages use them server-side to mirror the enforced gates in
+ * formBuilderConfig.ts, so users never see surveys they cannot submit.
+ */
+import type { Form } from "@/payload-types";
+
+export interface SurveyAudienceContext {
+ userId: number;
+ roleIds: number[];
+ qualificationNames: string[];
+}
+
+export function isSurveyOpen(
+ form: Pick