diff --git a/src/app/api/elections/pledge/route.ts b/src/app/api/elections/pledge/route.ts index 024d06ea..c6f71574 100644 --- a/src/app/api/elections/pledge/route.ts +++ b/src/app/api/elections/pledge/route.ts @@ -7,6 +7,7 @@ import { isSupportedElection, } from "@/lib/elections/registry"; import { forwardedHubspotContext } from "@/lib/hubspot-context"; +import { normalizePostalCode } from "@/lib/elections/postal-code"; // "Pledge to vote" submissions — same low-friction pattern as /api/subscribe. // Forwards {email, name, region, postal_code} to York Factory, which signs the @@ -19,17 +20,6 @@ import { forwardedHubspotContext } from "@/lib/hubspot-context"; // defaults to the election's jurisdiction ("toronto", "brampton", …). const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; -const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; - -// "M5V1A1" / "m5v 1a1" → "M5V 1A1"; anything malformed is dropped rather -// than stored dirty -function normalizePostalCode(raw: unknown): string | undefined { - if (typeof raw !== "string" || !POSTAL_PATTERN.test(raw.trim())) { - return undefined; - } - const compact = raw.trim().toUpperCase().replace(" ", ""); - return `${compact.slice(0, 3)} ${compact.slice(3)}`; -} export async function POST(req: NextRequest) { try { diff --git a/src/app/api/elections/survey/route.ts b/src/app/api/elections/survey/route.ts new file mode 100644 index 00000000..30fbcb5b --- /dev/null +++ b/src/app/api/elections/survey/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { API_URL } from "@/lib/api/client"; +import { + DEFAULT_ELECTION_SLUG, + getElection, + isSupportedElection, +} from "@/lib/elections/registry"; +import { normalizePostalCode } from "@/lib/elections/postal-code"; +import { forwardedHubspotContext } from "@/lib/hubspot-context"; + +// Resident-survey submissions — same shape as /api/elections/pledge. Forwards +// to York Factory, which signs the email up as a subscriber and records one +// response per subscriber per survey per election (re-submitting replaces the +// answers). +// +// `election` is checked against the registry before it reaches the API, so a +// client can't aim this at an arbitrary slug. The answers themselves are +// passed through untouched: the question set lives in the survey page's +// surveyData.ts and is meant to change without a deploy on either side, so +// validating question ids here would just reintroduce the coupling. York +// Factory applies structural limits (count, key and value length). + +const SLUG_PATTERN = /^[a-z0-9-]{1,100}$/; +const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + email, + name, + answers, + survey_slug, + survey_version, + region, + postal_code, + election, + } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 }, + ); + } + + if (!answers || typeof answers !== "object" || Array.isArray(answers)) { + return NextResponse.json( + { error: "Answers are required" }, + { status: 400 }, + ); + } + + if (election !== undefined && !isSupportedElection(election)) { + return NextResponse.json({ error: "Unknown election" }, { status: 400 }); + } + const electionSlug = isSupportedElection(election) + ? election + : DEFAULT_ELECTION_SLUG; + const config = getElection(electionSlug); + + if (typeof survey_slug !== "string" || !SLUG_PATTERN.test(survey_slug)) { + return NextResponse.json( + { error: "A survey_slug is required" }, + { status: 400 }, + ); + } + + // A malformed region is dropped rather than rejected — the ward is a + // nice-to-have for cutting results, not worth failing a completed survey + // over. The postal code is kept on the response so it can be re-derived. + const safeRegion = + typeof region === "string" && REGION_PATTERN.test(region) + ? region + : undefined; + + const res = await fetch( + `${API_URL}/elections/${electionSlug}/survey_responses`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + name: typeof name === "string" ? name.slice(0, 100) : undefined, + answers, + survey_slug, + survey_version: + typeof survey_version === "string" ? survey_version : undefined, + region: safeRegion, + postal_code: normalizePostalCode(postal_code), + ...forwardedHubspotContext(body, req), + }), + cache: "no-store", + }, + ); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + return NextResponse.json( + { error: errorData.errors?.[0] || "Survey submission failed" }, + { status: res.status }, + ); + } + + const data = await res.json(); + + return NextResponse.json({ + success: true, + election: config.slug, + surveySlug: data.survey_slug ?? survey_slug, + region: data.region ?? null, + derivedRegion: data.derived_region ?? null, + submittedAt: data.submitted_at ?? null, + }); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 192d890b..524724e0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -80,6 +80,28 @@ html { 100% { transform: rotate(0deg); } } +/* ─── Multi-step form transitions ─── */ + +/* Each field in a step rises into place as it fades in. Staggered by an + inline animation-delay from the field's index. `forwards` plus the + reduced-motion override above means these still end up visible when + animation is disabled. */ +@keyframes stepFieldIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.step-field { + opacity: 0; + animation: stepFieldIn 340ms cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + .accordion-expand { display: grid; grid-template-rows: 1fr; diff --git a/src/app/toronto/elections/2026/survey/SurveyClient.tsx b/src/app/toronto/elections/2026/survey/SurveyClient.tsx new file mode 100644 index 00000000..e270e499 --- /dev/null +++ b/src/app/toronto/elections/2026/survey/SurveyClient.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useState, type CSSProperties } from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; + +import { Select } from "@/components/ui/select"; + +import { + SURVEY_META, + SURVEY_STEPS, + SURVEY_STEP_COUNT, + YES_NO, + type SurveyQuestion, +} from "./surveyData"; +import { submitSurvey } from "./submitSurvey"; + +export type SurveyAnswers = Record; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; + +const FIELD_CLASS = + "w-full border border-border-light bg-white px-4 py-3.5 font-serif text-[17px] text-dark outline-none transition-colors focus:border-dark placeholder:text-text-muted"; +const CHOICE_CLASS = + "flex cursor-pointer items-center gap-3 border border-border-light bg-white px-4 py-3.5 text-[17px] transition-colors hover:border-dark has-checked:border-dark"; +const RADIO_CLASS = "size-[17px] m-0 accent-accent"; + +/** Entrance delay for the nth element in a step, capped so a long step's last + * field doesn't sit blank waiting its turn. */ +function stagger(index: number): CSSProperties { + return { animationDelay: `${Math.min(index, 6) * 45}ms` }; +} + +/** Per-question error copy, or null when the answer passes. */ +function errorFor(question: SurveyQuestion, value: string): string | null { + const answered = value.trim().length > 0; + + if (question.required && !answered) { + if (question.type === "radio") return "Choose one"; + if (question.type === "yesno") return "Choose yes or no"; + return "Required"; + } + if (!answered) return null; + + if (question.type === "email" && !EMAIL_PATTERN.test(value)) { + return "Enter a valid email"; + } + if (question.id === "postal_code" && !POSTAL_PATTERN.test(value)) { + return "Enter a valid postal code (e.g. M5V 2T6)"; + } + return null; +} + +export default function SurveyClient() { + const [step, setStep] = useState(0); + const [answers, setAnswers] = useState({}); + const [errors, setErrors] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [done, setDone] = useState(false); + + const isLastStep = step === SURVEY_STEP_COUNT - 1; + const currentStep = SURVEY_STEPS[step]; + + const set = (id: string, value: string) => { + setAnswers((prev) => ({ ...prev, [id]: value })); + // Clear the error as soon as they start fixing it; it comes back on Next. + setErrors((prev) => { + if (!prev[id]) return prev; + const rest = { ...prev }; + delete rest[id]; + return rest; + }); + }; + + const scrollTop = () => window.scrollTo({ top: 0, behavior: "smooth" }); + + /** Validates the current step, surfacing every failing field at once. */ + const validateStep = (): boolean => { + const found: Record = {}; + for (const question of currentStep.questions) { + // Optional fields still get format-checked once they're filled in. + const message = errorFor(question, answers[question.id] ?? ""); + if (message) found[question.id] = message; + } + setErrors(found); + return Object.keys(found).length === 0; + }; + + const next = () => { + if (!validateStep()) return; + setStep((s) => Math.min(SURVEY_STEP_COUNT - 1, s + 1)); + scrollTop(); + }; + + const back = () => { + setErrors({}); + setSubmitError(null); + setStep((s) => Math.max(0, s - 1)); + scrollTop(); + }; + + const submit = async () => { + if (!validateStep()) return; + setSubmitting(true); + setSubmitError(null); + try { + await submitSurvey(answers); + setDone(true); + scrollTop(); + } catch { + // Answers stay on screen so they can just press submit again. + setSubmitError("Something went wrong. Please try again."); + } finally { + setSubmitting(false); + } + }; + + const restart = () => { + setAnswers({}); + setErrors({}); + setSubmitError(null); + setStep(0); + setDone(false); + scrollTop(); + }; + + return ( +
+
+ {/* ── Masthead ───────────────────────────────────────── */} + + +
+

+ {SURVEY_META.title} +

+

+ {SURVEY_META.intro} +

+
+ + {done ? ( +
+
+

+ {SURVEY_META.thankYou.title} +

+

+ {SURVEY_META.thankYou.body} +

+
+ + + Explore the candidates + + +
+
+ ) : ( + <> + {/* ── Progress ───────────────────────────────────── */} +
+ + Step {step + 1} of {SURVEY_STEP_COUNT} + +
+
+
+
+ + {/* ── The current step ───────────────────────────── */} +
{ + e.preventDefault(); + if (isLastStep) void submit(); + else next(); + }} + > + {/* Keyed on the step so React remounts it and the entrance + animation replays on every move. */} +
+

+ {currentStep.title} +

+ {currentStep.intro && ( +

+ {currentStep.intro} +

+ )} + + {currentStep.questions.map((question, i) => ( + set(question.id, value)} + style={stagger(i + (currentStep.intro ? 2 : 1))} + /> + ))} +
+ + {/* ── Navigation ───────────────────────────────── */} +
+ {step > 0 ? ( + + ) : ( + + )} + +
+ {submitError && ( +

{submitError}

+ )} + +
+
+
+ + )} +
+
+ ); +} + +/* ── One question, rendered by type ─────────────────────────── */ + +function Question({ + question, + value, + error, + onChange, + style, +}: { + question: SurveyQuestion; + value: string; + error?: string; + onChange: (value: string) => void; + /** staggered entrance delay from the parent step */ + style?: CSSProperties; +}) { + // Choice groups get a ; single inputs get a real