Skip to content
Merged
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-color": "^3.1.0",
"d3-format": "^3.1.2",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"fast-xml-parser": "^5.3.8",
Expand Down Expand Up @@ -70,6 +74,10 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/d3-color": "^3.1.3",
"@types/d3-format": "^3.0.4",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.2.0",
"@types/hast": "^3.0.5",
"@types/node": "^20",
"@types/react": "^19",
Expand Down
58 changes: 58 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions src/app/api/elections/candidate-responses/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";

import {
CANDIDATE_QUESTIONNAIRE_SLUG,
fetchCandidateResponses,
} from "@/lib/elections/candidate-responses";
import {
DEFAULT_ELECTION_SLUG,
isSupportedElection,
} from "@/lib/elections/registry";
import {
TORONTO_2026_SLUG,
getToronto2026,
getToronto2026Ward,
nameKey,
} from "@/app/toronto/vote/2026/data";

// Published candidate questionnaire answers for one ward.
//
// A read proxy, not a data source: everything it returns is already public
// through York Factory's own endpoint, and it exists because the survey page
// only learns which ward to ask about after the API has placed the respondent
// from their postal code. Fetching it server-side at page load would mean
// shipping every ward's answers to every visitor to use one ward's worth.
//
// The mayoral field rides along too. A voter marks two ballots — one for their
// councillor, one for mayor — so a comparison that answers only half of that is
// answering the smaller half: the mayoral race is the one every voter in the
// city votes in.
//
// The ward's roster rides along with the answers. The comparison names every
// candidate on the ballot, not only the ones who wrote back — a reader wants to
// know that the candidate they are considering said nothing as much as they
// want to know what the others said — and the roster is the only place that
// fact lives. Toronto is the only region with a survey page, so it is the only
// region this looks one up for; everyone else gets an empty roster and a
// comparison of respondents alone.
//
// `election` is checked against the registry so a client cannot aim this at an
// arbitrary slug, matching the submit route.

const WARD_PATTERN = /^\d{1,2}$/;

export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const election = searchParams.get("election") ?? DEFAULT_ELECTION_SLUG;
const ward = searchParams.get("ward") ?? "";

if (!isSupportedElection(election)) {
return NextResponse.json({ error: "Unknown election" }, { status: 400 });
}
if (!WARD_PATTERN.test(ward)) {
return NextResponse.json({ error: "Invalid ward" }, { status: 400 });
}

const toronto = election === TORONTO_2026_SLUG;
const wardToken = ward.padStart(2, "0");

const [data, detail, view, everyResponse] = await Promise.all([
fetchCandidateResponses(election, {
ward,
surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG,
}),
toronto ? getToronto2026Ward(wardToken).catch(() => null) : null,
toronto ? getToronto2026().catch(() => null) : null,
// Unfiltered, because the mayoral field is on no ward: the responses are
// narrowed to the mayoral roster by name below, the same join every other
// surface uses.
toronto
? fetchCandidateResponses(election, {
surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG,
})
: [],
]);

const roster = (detail?.councilRaces ?? [])
.flatMap((race) => race.candidates)
.map(rosterEntry);

const mayoralRoster = (view?.mayoral ?? []).map(rosterEntry);
const mayoralKeys = new Set(mayoralRoster.map((candidate) => candidate.key));
const mayoralData = everyResponse.filter((response) =>
mayoralKeys.has(nameKey(response.candidateName)),
);

return NextResponse.json({
data,
roster,
mayoral: { data: mayoralData, roster: mayoralRoster },
});
}

function rosterEntry(candidate: {
key: string;
name: string;
website?: string;
withdrawn: boolean;
}) {
return {
key: candidate.key,
name: candidate.name,
website: candidate.website,
withdrawn: candidate.withdrawn,
};
}
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
125 changes: 125 additions & 0 deletions src/app/api/elections/survey/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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 is York Factory's, served from
// there and rendered by the survey page, so validating question ids in this
// proxy would only add a third copy of them to keep in step. York Factory
// applies structural limits (count, key and value length) and owns the
// question ids on both sides of the round trip.

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 @@ -117,6 +117,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