diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx
index 6f025af..f8a01f1 100644
--- a/src/app/(frontend)/layout.tsx
+++ b/src/app/(frontend)/layout.tsx
@@ -14,6 +14,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
+import { SurveyReminderToasts } from "@/components/frontend/surveys/SurveyReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
import { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner";
@@ -225,6 +226,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
{user && }
{process.env.NODE_ENV === "development" && user && }
{user && }
+ {user && }
{isLogistics && }
diff --git a/src/app/api/surveys/pending/route.ts b/src/app/api/surveys/pending/route.ts
new file mode 100644
index 0000000..02c4dee
--- /dev/null
+++ b/src/app/api/surveys/pending/route.ts
@@ -0,0 +1,25 @@
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { NextResponse } from "next/server";
+import { getPendingSurveys } from "@/lib/surveys/pending";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * GET /api/surveys/pending — the signed-in user's open, eligible, uncompleted
+ * surveys. Polled by the layout-mounted SurveyReminderToasts (5 min) so users
+ * are reminded about surveys they can still take.
+ */
+export async function GET(request: Request) {
+ const payload = await getPayload({ config });
+ const { user } = await payload.auth({ headers: request.headers, canSetHeaders: false });
+
+ if (!user || user.collection !== "users") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const surveys = await getPendingSurveys(payload, user);
+ return NextResponse.json({
+ surveys: surveys.map((s) => ({ id: s.id, title: s.title })),
+ });
+}
diff --git a/src/components/frontend/surveys/SurveyReminderToasts.tsx b/src/components/frontend/surveys/SurveyReminderToasts.tsx
new file mode 100644
index 0000000..aa6dae2
--- /dev/null
+++ b/src/components/frontend/surveys/SurveyReminderToasts.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { ClipboardCheck } from "lucide-react";
+
+const POLL_MS = 5 * 60_000;
+const THROTTLE_MS = 15 * 60_000;
+const FIRST_CHECK_DELAY_MS = 45_000;
+const STORAGE_KEY = "ptf:survey:reminder-at";
+
+interface PendingSurvey {
+ id: number;
+ title: string;
+}
+
+/**
+ * Periodic reminder for surveys the user can take but has not completed.
+ * Polls /api/surveys/pending (open + eligible + unsubmitted, resolved
+ * server-side) and toasts at most once per THROTTLE_MS while surveys are
+ * pending. The toast action deep-links into the /surveys modal.
+ */
+export function SurveyReminderToasts() {
+ const router = useRouter();
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const check = async () => {
+ try {
+ const res = await fetch("/api/surveys/pending", {
+ credentials: "same-origin",
+ });
+ if (!res.ok || cancelled) return;
+
+ const data = (await res.json()) as { surveys?: PendingSurvey[] };
+ const surveys = data.surveys ?? [];
+ if (cancelled || surveys.length === 0) return;
+
+ const lastNotifiedAt = Number(localStorage.getItem(STORAGE_KEY) ?? 0);
+ if (Date.now() - lastNotifiedAt < THROTTLE_MS) return;
+ localStorage.setItem(STORAGE_KEY, String(Date.now()));
+
+ const first = surveys[0];
+ const waiting =
+ surveys.length === 1
+ ? "A survey is waiting for your input..."
+ : `${surveys.length} surveys are waiting for your input..`;
+ toast(waiting, {
+ description: surveys.length === 1 ? first.title : "Share your input before they close.",
+ icon: ,
+ action: {
+ label: "Take survey",
+ onClick: () => router.push(`/surveys?open=${first.id}`),
+ },
+ duration: 8000,
+ });
+ } catch (error) {
+ console.error("Survey reminder check failed:", error);
+ }
+ };
+
+ const firstCheck = setTimeout(check, FIRST_CHECK_DELAY_MS);
+ const interval = setInterval(check, POLL_MS);
+ return () => {
+ cancelled = true;
+ clearTimeout(firstCheck);
+ clearInterval(interval);
+ };
+ }, [router]);
+
+ return null;
+}
diff --git a/src/lib/surveys/pending.ts b/src/lib/surveys/pending.ts
new file mode 100644
index 0000000..a060ab8
--- /dev/null
+++ b/src/lib/surveys/pending.ts
@@ -0,0 +1,70 @@
+/**
+ * Server-side pending-survey resolution: open + audience-eligible + not yet
+ * submitted by this user. Single source of truth for the /surveys list page,
+ * the /api/surveys/pending poll endpoint, and the reminder toast, so the
+ * client-side reminder can never advertise a survey the gates would reject.
+ */
+import type { Payload, User as PayloadUser } from "payload";
+import type { Form, User } from "@/payload-types";
+import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
+
+function audienceContext(payload: Payload, 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, payload };
+}
+
+async function qualificationNames(payload: Payload, userId: number): Promise {
+ const profileRes = await payload.find({
+ collection: "profiles",
+ where: { user: { equals: userId } },
+ limit: 1,
+ depth: 1,
+ select: { progression: { qualifications: true } },
+ });
+
+ return (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);
+}
+
+/**
+ * Full survey docs the user can still take: inside the availability window,
+ * matching the audience targeting, and not yet submitted by them.
+ */
+export async function getPendingSurveys(payload: Payload, user: User): Promise