Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions src/app/api/elections/pledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions src/app/api/elections/survey/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
22 changes: 22 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading