diff --git a/.env.local.example b/.env.local.example
index 9b81a401..84a50064 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -41,3 +41,12 @@ MONGO_URI=
# secret — set one of these in every deployed environment.
NEXTAUTH_SECRET=
# AUTH_SECRET is accepted as an alias.
+
+# Customer.io Track API credentials, used to identify Build the Vote
+# volunteers (src/lib/customerio.ts). Same workspace York Factory identifies
+# subscribers to. Leave unset locally unless you mean to write to that
+# workspace; the identify is then skipped. CUSTOMERIO_REGION=eu for an EU
+# workspace.
+CUSTOMERIO_SITE_ID=
+CUSTOMERIO_API_KEY=
+CUSTOMERIO_REGION=
diff --git a/src/app/api/volunteer/route.ts b/src/app/api/volunteer/route.ts
new file mode 100644
index 00000000..8b7d88fb
--- /dev/null
+++ b/src/app/api/volunteer/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { identifyToCustomerio } from "@/lib/customerio";
+import { normalizePostalCode } from "@/lib/elections/postal-code";
+
+// Build the Vote sign-ups, identified to Customer.io. The sign-up itself goes
+// to CJPAC's Formstack; this copies who signed up into our own Customer.io so
+// we can follow up. Deliberately not York Factory: these aren't newsletter
+// subscribers created by our own form.
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const { email, first_name, last_name, city, postal_code } = body;
+
+ if (!email || typeof email !== "string") {
+ return NextResponse.json({ error: "Email is required" }, { status: 400 });
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email) || email.length > 254) {
+ return NextResponse.json(
+ { error: "Invalid email format" },
+ { status: 400 },
+ );
+ }
+
+ const str = (v: unknown, max: number) =>
+ typeof v === "string" ? v.trim().slice(0, max) : undefined;
+ const firstName = str(first_name, 100);
+ const lastName = str(last_name, 100);
+
+ const identified = await identifyToCustomerio(email.trim().toLowerCase(), {
+ first_name: firstName,
+ last_name: lastName,
+ name: [firstName, lastName].filter(Boolean).join(" "),
+ city: str(city, 100),
+ postal_code: normalizePostalCode(postal_code),
+ toronto_2026_build_the_vote_volunteer: true,
+ // A Unix timestamp, so date-based segments can use it.
+ toronto_2026_build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
+ });
+
+ // `identified` is false when Customer.io isn't configured (local dev).
+ return NextResponse.json({ success: true, identified });
+ } catch (err) {
+ return NextResponse.json({ error: String(err) }, { status: 500 });
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 459b5e79..878b49fa 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -8,6 +8,9 @@
/* Bills sub-app theme — scoped under .bills-scope (see src/app/bills/bills.css). */
@import "./bills/bills.css";
+/* CJPAC's Formstack forms on the Build the Vote pages. */
+@import "./toronto/volunteer/formstack.css";
+
html {
scroll-behavior: auto;
-webkit-font-smoothing: antialiased;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 67b265bc..d04bf8ea 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -57,28 +57,30 @@ export default function RootLayout({
{PUBLICATION_FEEDS.map(({ file, title }) => (
))}
-
-
+ var tag = document.createElement('script');
+ tag.async = true;
+ tag.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId || adsId);
+ document.head.appendChild(tag);
+ })();
+ `}
+
+
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index d8d1c65c..1b554de9 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -18,6 +18,7 @@ import {
KEY_DATES_PATH,
VOTE_BY_MAIL_PATH,
} from "@/app/toronto/vote/2026/key-dates";
+import { VOLUNTEER_PATH } from "@/app/toronto/volunteer/build-the-vote";
function toValidDate(value?: Date | string): Date | undefined {
if (!value) return undefined;
@@ -106,6 +107,13 @@ export default async function sitemap(): Promise {
changeFrequency: "weekly",
priority: 0.6,
},
+ // Build the Vote's landing page. Its step-2 and thanks pages are noindex.
+ {
+ url: `${baseUrl}${VOLUNTEER_PATH}`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 0.7,
+ },
...WARD_NUMBERS.map((n) => ({
url: `${baseUrl}/toronto/vote/2026/wards/${n}`,
lastModified: new Date(),
diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx
new file mode 100644
index 00000000..4e61cfaa
--- /dev/null
+++ b/src/app/toronto/volunteer/FormstackForm.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+import { useEffect, useEffectEvent, useRef, useState } from "react";
+
+const FORMSTACK_ORIGIN = "https://cjpac.formstack.com";
+
+/**
+ * Embeds one of CJPAC's Formstack forms and adjusts it to fit our page.
+ *
+ * Formstack's embed code is a script that document.write()s the form in place.
+ * That only works while the HTML is still being parsed, so it can't run from a
+ * React effect (and would wipe the page if it did). Instead we load it with
+ * document.write captured, then insert what it wrote ourselves, running its
+ * scripts in order. The form itself runs untouched; only where it lands moves.
+ *
+ * Formstack renders the form with React. Hiding fields with a stylesheet
+ * keyed on their data-testid survives its re-renders, where setting
+ * style.display on a node would not. Values are written through the native
+ * setter plus an input event, which is what Formstack's controlled inputs
+ * listen for; assigning `.value` directly would be overwritten on the next
+ * render.
+ *
+ * `onSubmit` sees each submit that passes the form's own required-field
+ * checks, before Formstack sends it. It's for copying the answers elsewhere;
+ * it can't stop or delay the submit.
+ *
+ * `onSubmitted` keeps the visitor on our page. Formstack submits with a plain
+ * form POST, so the page would follow the form's own redirect, which CJPAC
+ * sets in Formstack and which may point back at their hosted forms. With
+ * `onSubmitted`, the form posts into a hidden iframe instead: the submission
+ * and its redirect happen in there, and when the iframe loads we call
+ * `onSubmitted` so the page can go where we choose. The response is
+ * cross-origin, so we can't read it; the iframe loading means Formstack took
+ * the POST.
+ */
+export function FormstackForm({
+ slug,
+ fill = {},
+ check = [],
+ hide = [],
+ onSubmit,
+ onSubmitted,
+}: {
+ /** the form's Formstack slug, e.g. "buildthevote_step1" */
+ slug: string;
+ /** text and hidden fields to fill, by Formstack field ID */
+ fill?: Record;
+ /** checkbox fields to tick, by Formstack field ID */
+ check?: string[];
+ /** fields to hide, by Formstack field ID */
+ hide?: string[];
+ /** called with the form's values on a valid submit */
+ onSubmit?: (values: FormData) => void;
+ /** called once Formstack has received the submission; see above */
+ onSubmitted?: () => void;
+}) {
+ const containerRef = useRef(null);
+ const [status, setStatus] = useState<"loading" | "ready" | "failed">(
+ "loading",
+ );
+
+ // The props are fixed per page, so an effect keyed on their contents runs
+ // once per mount, not on every render that passes a fresh object.
+ const fillKey = JSON.stringify(fill);
+ const checkKey = check.join(",");
+
+ const sinkName = `formstack-${slug}`;
+ // An iframe fires load once for its initial blank page; only a load after a
+ // submit is Formstack's response.
+ const submittedRef = useRef(false);
+
+ // Listening in the capture phase runs this before Formstack's own handler,
+ // which may navigate away. checkValidity() still works under the form's
+ // novalidate; it reads the required and type rules on each input.
+ const handleSubmit = useEffectEvent((event: Event) => {
+ const form = event.target;
+ if (!(form instanceof HTMLFormElement)) return;
+ if (onSubmitted) {
+ // Set before Formstack's handler calls form.submit(), which honours it.
+ form.target = sinkName;
+ submittedRef.current = true;
+ }
+ if (form.checkValidity()) onSubmit?.(new FormData(form));
+ });
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.addEventListener("submit", handleSubmit, true);
+ return () => container.removeEventListener("submit", handleSubmit, true);
+ }, []);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ let cancelled = false;
+ let observer: MutationObserver | undefined;
+
+ captureWrites(`${FORMSTACK_ORIGIN}/forms/js.php/${slug}`)
+ .then(async (html) => {
+ if (cancelled) return;
+ await insertHtml(container, html);
+ if (cancelled) return;
+ observer = applyWhenRendered(
+ container,
+ JSON.parse(fillKey) as Record,
+ checkKey ? checkKey.split(",") : [],
+ );
+ setStatus("ready");
+ })
+ .catch(() => {
+ if (!cancelled) setStatus("failed");
+ });
+
+ return () => {
+ cancelled = true;
+ observer?.disconnect();
+ container.replaceChildren();
+ };
+ }, [slug, fillKey, checkKey]);
+
+ const hidden = hide
+ .map((id) => `[data-testid="field-${id}"],[data-testid^="field-${id}-"]`)
+ .join(",");
+
+ return (
+
+ );
+}
+
+/** Runs a document.write()-based embed script and returns what it wrote. */
+function captureWrites(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: string[] = [];
+ const { write, writeln } = document;
+ document.write = (...text: string[]) => {
+ chunks.push(text.join(""));
+ };
+ document.writeln = (...text: string[]) => {
+ chunks.push(text.join("") + "\n");
+ };
+ const restore = () => {
+ document.write = write;
+ document.writeln = writeln;
+ script.remove();
+ };
+
+ const script = document.createElement("script");
+ script.src = src;
+ script.onload = () => {
+ restore();
+ resolve(chunks.join(""));
+ };
+ script.onerror = () => {
+ restore();
+ reject(new Error(`Failed to load ${src}`));
+ };
+ document.head.appendChild(script);
+ });
+}
+
+/** Shared across mounts so navigating back to a form doesn't execute
+ * Formstack's renderer bundle a second time. */
+const loadedScripts = new Map>();
+
+/**
+ * Inserts HTML that contains scripts, running them in document order the way
+ * the parser would have. Scripts inserted via innerHTML never execute, so each
+ * one is recreated; an external script has to finish loading before the
+ * inline script after it (which calls into it) runs.
+ */
+async function insertHtml(container: HTMLElement, html: string) {
+ const template = document.createElement("template");
+ template.innerHTML = html;
+
+ for (const node of Array.from(template.content.childNodes)) {
+ if (!(node instanceof HTMLScriptElement)) {
+ container.appendChild(node);
+ continue;
+ }
+
+ if (node.src) {
+ const src = node.src;
+ let loaded = loadedScripts.get(src);
+ if (!loaded) {
+ loaded = new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = src;
+ script.onload = () => resolve();
+ script.onerror = () => {
+ loadedScripts.delete(src);
+ reject(new Error(`Failed to load ${src}`));
+ };
+ document.head.appendChild(script);
+ });
+ loadedScripts.set(src, loaded);
+ }
+ await loaded;
+ } else {
+ const script = document.createElement("script");
+ script.textContent = node.textContent;
+ container.appendChild(script);
+ }
+ }
+}
+
+/**
+ * Fills and ticks fields as Formstack renders them. Its render is async, so
+ * the inputs may not exist yet when the scripts finish; we watch for each one
+ * rather than assuming it's there. Gives up after 20 seconds.
+ */
+function applyWhenRendered(
+ container: HTMLElement,
+ fill: Record,
+ check: string[],
+): MutationObserver | undefined {
+ const pendingFill = new Map(Object.entries(fill).filter(([, v]) => v));
+ const pendingCheck = new Set(check);
+
+ const apply = () => {
+ for (const [id, value] of pendingFill) {
+ const input = container.querySelector(
+ `[name="field${id}"]`,
+ );
+ if (!input) continue;
+ if (input.value !== value) setNativeValue(input, value);
+ pendingFill.delete(id);
+ }
+ for (const id of pendingCheck) {
+ const box = container.querySelector(
+ `input[type="checkbox"][name="field${id}[]"]`,
+ );
+ if (!box) continue;
+ // A click, not `checked = true`, so Formstack's own handler records it.
+ if (!box.checked) box.click();
+ pendingCheck.delete(id);
+ }
+ return pendingFill.size === 0 && pendingCheck.size === 0;
+ };
+
+ if (apply()) return undefined;
+
+ const observer = new MutationObserver(() => {
+ if (apply()) observer.disconnect();
+ });
+ observer.observe(container, { childList: true, subtree: true });
+ setTimeout(() => observer.disconnect(), 20_000);
+ return observer;
+}
+
+function setNativeValue(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ setter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+}
diff --git a/src/app/toronto/volunteer/SignupTracker.tsx b/src/app/toronto/volunteer/SignupTracker.tsx
new file mode 100644
index 00000000..5a611423
--- /dev/null
+++ b/src/app/toronto/volunteer/SignupTracker.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect } from "react";
+import posthog from "posthog-js";
+
+/**
+ * Records a Build the Vote sign-up. The form lives in CJPAC's Formstack, so we
+ * never see the submit itself. Landing on step 2 is the closest signal we have:
+ * step 1's redirect is the only link to it.
+ */
+export function SignupTracker() {
+ useEffect(() => {
+ // Inside the hidden iframe a form posts into (see FormstackForm), should
+ // CJPAC's redirect ever point here: the visible page records it instead.
+ if (window !== window.top) return;
+ posthog.capture("volunteer_signed_up", { program: "build_the_vote" });
+ }, []);
+
+ return null;
+}
diff --git a/src/app/toronto/volunteer/VolunteerSignupForm.tsx b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
new file mode 100644
index 00000000..c712e491
--- /dev/null
+++ b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useRef } from "react";
+import posthog from "posthog-js";
+import { FormstackForm } from "./FormstackForm";
+import { PARTNER_SOURCE, STEP_1, STEP_2_PATH } from "./build-the-vote";
+
+const { fields } = STEP_1;
+
+/**
+ * Step 1 of the sign-up. Formstack sends the volunteer to CJPAC; we also
+ * identify them in PostHog and, through /api/volunteer, in Customer.io.
+ *
+ * Formstack may leave the page as soon as its submit handler runs, so the
+ * Customer.io call goes as a keepalive request that outlives the navigation,
+ * and nothing waits on it. Phone stays with CJPAC: the consent line covers
+ * Build Canada email, not calls from us.
+ *
+ * Once Formstack has the submission we send the volunteer to our own step 2
+ * ourselves, rather than following the redirect set in CJPAC's Formstack. The
+ * email rides along in the URL as that redirect's would; the proxy moves it
+ * into a cookie before the page renders (see src/proxy.ts).
+ */
+export function VolunteerSignupForm() {
+ const emailRef = useRef("");
+
+ const handleSubmit = (values: FormData) => {
+ const value = (name: string) => {
+ const v = values.get(`field${name}`);
+ return typeof v === "string" ? v.trim() : "";
+ };
+ const email = value(fields.email);
+ if (!email) return;
+ emailRef.current = email;
+
+ posthog.identify(email, { email });
+
+ fetch("/api/volunteer", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ keepalive: true,
+ body: JSON.stringify({
+ email,
+ first_name: value(`${fields.name}-first`),
+ last_name: value(`${fields.name}-last`),
+ city: value(`${fields.address}-city`),
+ postal_code: value(`${fields.address}-zip`),
+ }),
+ }).catch(() => {
+ // Best-effort: CJPAC still has the sign-up.
+ });
+ };
+
+ return (
+ {
+ const email = emailRef.current;
+ window.location.assign(
+ email
+ ? `${STEP_2_PATH}/?email=${encodeURIComponent(email)}`
+ : `${STEP_2_PATH}/`,
+ );
+ }}
+ />
+ );
+}
diff --git a/src/app/toronto/volunteer/build-the-vote.ts b/src/app/toronto/volunteer/build-the-vote.ts
new file mode 100644
index 00000000..eff546df
--- /dev/null
+++ b/src/app/toronto/volunteer/build-the-vote.ts
@@ -0,0 +1,62 @@
+// Build the Vote: the Election Day volunteer drive we run with CJPAC.
+//
+// The two sign-up forms live in CJPAC's Formstack account, not ours. CJPAC
+// owns the forms' fields and their submit redirects (step 1 → STEP_2_PATH with
+// the volunteer's email, step 2 → THANKS_PATH), and receives every lead. What
+// we own is the page around each form, so every Formstack ID this site depends
+// on is collected here: if CJPAC rebuilds a form, this is the file to update.
+
+export const VOLUNTEER_PATH = "/toronto/volunteer";
+export const STEP_2_PATH = `${VOLUNTEER_PATH}/step-2`;
+export const THANKS_PATH = `${VOLUNTEER_PATH}/thanks`;
+
+/** Written into each form's "UTM Source" field so CJPAC can tell our sign-ups
+ * from those of their other partners. Not the visitor's own utm_source: that
+ * stays on the URL for our analytics and lands in the form's hidden
+ * "Campaign Source" field on its own. */
+export const PARTNER_SOURCE = "buildcanada";
+
+export const STEP_1 = {
+ slug: "buildthevote_step1",
+ fields: {
+ /** Split by Formstack into `-first` and `-last` inputs. */
+ name: "199478140",
+ email: "199478141",
+ /** Asks only for city and postal code: `-city` and `-zip` inputs. */
+ address: "199478143",
+ /** Visible in the form as built, so we fill it with PARTNER_SOURCE and
+ * hide it. */
+ utmSource: "199478149",
+ /** The opt-in checkbox for Build Canada email. Replaced by the consent
+ * line under the form, which makes submitting the form the opt-in. */
+ emailOptIn: "199478245",
+ /** CJPAC's own consent copy. Ours (CONSENT below) includes it and adds
+ * Build Canada email, so theirs is hidden rather than shown twice. */
+ consent: "199478150",
+ },
+} as const;
+
+export const STEP_2 = {
+ slug: "buildthevote_step2",
+ fields: {
+ /** Hidden in the form. Carries the email from step 1 so CJPAC can join
+ * the two submissions. */
+ email: "199478152",
+ },
+} as const;
+
+/** Query parameters the step 1 redirect may carry the volunteer's email in.
+ * The proxy strips both before the page renders (see src/proxy.ts). */
+export const EMAIL_PARAMS = ["email", `field${STEP_2.fields.email}`] as const;
+
+/** Holds the email across the proxy's redirect to the clean step-2 URL. */
+export const EMAIL_COOKIE = "btv_email";
+
+export const CONSENT =
+ "By submitting this form, you consent to being contacted by a CJPAC staff member or volunteer and to receive Build Canada emails.";
+
+export const CJPAC_URL = "https://www.cjpac.ca";
+
+/** Election Day training, Toronto time. The Zoom link itself goes out in the
+ * confirmation email, never on a public page. */
+export const TRAINING_DATES = "October 19, 20, 21, 22 and 25";
diff --git a/src/app/toronto/volunteer/formstack.css b/src/app/toronto/volunteer/formstack.css
new file mode 100644
index 00000000..88b2d077
--- /dev/null
+++ b/src/app/toronto/volunteer/formstack.css
@@ -0,0 +1,132 @@
+/*
+ * CJPAC's Formstack forms, restyled to match our own inputs (see SubscribeForm)
+ * and Button. Imported once from src/app/globals.css.
+ *
+ * Formstack styles the form with styled-components: hashed class names that
+ * change between renderer releases, so these rules hang off the stable fs*
+ * classes and element types instead. Its rules arrive after ours and can be as
+ * specific, hence the !important on anything it also sets.
+ */
+
+.formstack-embed .fsBody,
+.formstack-embed .fsForm,
+.formstack-embed .fsPage,
+.formstack-embed .fsSection,
+.formstack-embed .fsBody > div {
+ background: transparent !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ max-width: none !important;
+ width: 100% !important;
+ border: 0 !important;
+ box-shadow: none !important;
+}
+
+.formstack-embed .fsFieldCell {
+ margin: 0 0 1.25rem !important;
+ padding: 0 !important;
+}
+
+.formstack-embed .fsLabel,
+.formstack-embed legend {
+ font-family: var(--font-label) !important;
+ font-size: 0.75rem !important;
+ font-weight: 500 !important;
+ letter-spacing: 0.14em !important;
+ line-height: 1.3 !important;
+ text-transform: uppercase !important;
+ color: var(--color-dark) !important;
+ margin-bottom: 0.5rem !important;
+}
+
+.formstack-embed .fsRequiredMarker {
+ color: var(--color-accent) !important;
+ margin-left: 0.25em;
+}
+
+/* Sub-labels under split fields ("First Name", "Postal Code") and supporting
+ text. */
+.formstack-embed .fsSubField label,
+.formstack-embed .fsSupportingSubLabel,
+.formstack-embed .fsSupporting {
+ font-family: var(--font-serif) !important;
+ font-size: 0.95rem !important;
+ color: var(--color-text-secondary) !important;
+ margin-top: 0.35rem !important;
+}
+
+.formstack-embed input[type="text"],
+.formstack-embed input[type="email"],
+.formstack-embed input[type="tel"],
+.formstack-embed select,
+.formstack-embed textarea {
+ width: 100% !important;
+ border: 1px solid var(--color-charcoal-300) !important;
+ border-radius: 0 !important;
+ background: #fff !important;
+ color: var(--color-dark) !important;
+ font-family: var(--font-serif) !important;
+ font-size: 1.05rem !important;
+ line-height: 1.4 !important;
+ padding: 0.65rem 0.75rem !important;
+ height: auto !important;
+ box-shadow: none !important;
+ outline: none !important;
+ transition: border-color 0.15s;
+}
+
+.formstack-embed input:focus,
+.formstack-embed select:focus,
+.formstack-embed textarea:focus {
+ border-color: var(--color-dark) !important;
+}
+
+.formstack-embed .fsOptionLabel {
+ font-family: var(--font-serif) !important;
+ font-size: 1.05rem !important;
+ color: var(--color-dark) !important;
+ display: flex !important;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.formstack-embed input[type="checkbox"],
+.formstack-embed input[type="radio"] {
+ accent-color: var(--color-accent);
+}
+
+.formstack-embed .fsSubmit,
+.formstack-embed [id^="fsSubmit"] {
+ background: transparent !important;
+ padding: 0.5rem 0 0 !important;
+ margin: 0 !important;
+ border: 0 !important;
+ text-align: left !important;
+}
+
+/* Matches Button's "auburn" variant, which reads as Toronto blue inside the
+ election theme. */
+.formstack-embed .fsSubmitButton {
+ background: var(--color-auburn-800) !important;
+ border: 1px solid var(--color-auburn-800) !important;
+ border-radius: 0 !important;
+ color: #fff !important;
+ font-family: var(--font-label) !important;
+ font-size: 0.75rem !important;
+ font-weight: 500 !important;
+ letter-spacing: 0.14em !important;
+ text-transform: uppercase !important;
+ padding: 1.1rem 1.75rem !important;
+ width: auto !important;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.formstack-embed .fsSubmitButton:hover {
+ opacity: 0.8;
+}
+
+.formstack-embed .fsSubmitButton:focus-visible {
+ outline: 2px solid var(--color-dark) !important;
+ outline-offset: 3px;
+}
diff --git a/src/app/toronto/volunteer/gutter.ts b/src/app/toronto/volunteer/gutter.ts
new file mode 100644
index 00000000..7235e5bd
--- /dev/null
+++ b/src/app/toronto/volunteer/gutter.ts
@@ -0,0 +1,24 @@
+// Side padding for the Build the Vote pages. Backgrounds and rules run the
+// full width of the frame, but on wide screens the padding grows so the
+// content sits in a centred column about 1080px wide, like the original
+// design. Below about 1200px the max() falls back to the usual md:px-14.
+//
+// Measured against the viewport rather than with percentages, so the left
+// cell of a two-column section and a full-width section line up: a
+// percentage would resolve against each grid cell's own width.
+
+/** Both sides, for full-width blocks. */
+export const GUTTER = "px-6 md:px-[max(3.5rem,calc((100vw_-_1100px)/2))]";
+
+/** The left column of a two-column section. */
+export const GUTTER_LEFT =
+ "px-6 md:px-14 md:pl-[max(3.5rem,calc((100vw_-_1100px)/2))]";
+
+/** The right column of a two-column section. */
+export const GUTTER_RIGHT =
+ "px-6 md:px-14 md:pr-[max(3.5rem,calc((100vw_-_1100px)/2))]";
+
+/** A grid of padded cells (md:px-9), inset so the cells' text lines up with
+ * the GUTTER text above and below it. */
+export const GUTTER_CELLS =
+ "md:px-[max(1.25rem,calc((100vw_-_1100px)/2_-_2.25rem))]";
diff --git a/src/app/toronto/volunteer/opengraph-image.tsx b/src/app/toronto/volunteer/opengraph-image.tsx
new file mode 100644
index 00000000..02f7ad5b
--- /dev/null
+++ b/src/app/toronto/volunteer/opengraph-image.tsx
@@ -0,0 +1,17 @@
+import { ImageResponse } from "next/og";
+import { ElectionOGImage, OG_SIZE, logoDataUri } from "../vote/2026/election-og";
+
+export const alt = "Build the Vote: volunteer on Toronto's Election Day — Build Canada";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+export default async function Image() {
+ return new ImageResponse(
+ ,
+ { ...size },
+ );
+}
diff --git a/src/app/toronto/volunteer/page.tsx b/src/app/toronto/volunteer/page.tsx
new file mode 100644
index 00000000..98849cb7
--- /dev/null
+++ b/src/app/toronto/volunteer/page.tsx
@@ -0,0 +1,455 @@
+import type { Metadata } from "next";
+import { Button } from "@/components/ui/button";
+import { ELECTION, ELECTION_DAY, HOW_TO_VOTE_PATH } from "../vote/2026/key-dates";
+import { PeriodCountdown } from "../vote/2026/PeriodCountdown";
+import {
+ CJPAC_URL,
+ CONSENT,
+ TRAINING_DATES,
+ VOLUNTEER_PATH,
+} from "./build-the-vote";
+import { GUTTER, GUTTER_CELLS, GUTTER_LEFT, GUTTER_RIGHT } from "./gutter";
+import { VolunteerSignupForm } from "./VolunteerSignupForm";
+
+/* Hourly, like the other pages with an election-day countdown: the server's
+ reading is the number crawlers and no-JS readers see, and the client
+ corrects it to the second on mount. */
+export const revalidate = 3600;
+
+const DESCRIPTION =
+ "The Toronto you know is possible doesn't vote itself in. Give two hours on October 26 to help a candidate who shares your values win their race.";
+
+export const metadata: Metadata = {
+ title: "Build the Vote: Volunteer on Election Day",
+ description: DESCRIPTION,
+ alternates: { canonical: VOLUNTEER_PATH },
+ openGraph: {
+ title: "Build the Vote: volunteer on Toronto's Election Day",
+ description:
+ "Give two hours on October 26 to help a candidate who shares your values win their race. No experience needed.",
+ type: "website",
+ url: VOLUNTEER_PATH,
+ },
+};
+
+const ISSUES = [
+ {
+ title: "Housing",
+ body: "How many new homes get approved across every part of the city, and how long approvals take.",
+ },
+ {
+ title: "Transit",
+ body: "How frequent and reliable service is, and how quickly new lines move from plan to construction.",
+ },
+ {
+ title: "Delivering projects",
+ body: "Whether major projects are completed on schedule and on budget, with cost and timeline data published openly.",
+ },
+ {
+ title: "Value for money",
+ body: "How clearly city spending is tied to measurable results that improve daily life.",
+ },
+];
+
+/* 2022 results, from the City of Toronto's official returns. */
+const MARGINS = [
+ { votes: 94, race: "Ward 5", detail: "city council race." },
+ { votes: 63, race: "Ward 18", detail: "school board race." },
+ {
+ votes: 1,
+ race: "Ward 1",
+ detail: "TCDSB trustee race, out of more than 4,000 cast.",
+ },
+];
+
+const STEPS = [
+ {
+ title: "Sign up to volunteer on Election Day",
+ body: "It takes about a minute.",
+ },
+ {
+ title: "A volunteer coordinator calls you",
+ body: "They’ll match you with a candidate who shares your values, in a race where your time makes a difference.",
+ },
+ {
+ title: "Show up on October 26",
+ body: "Join your candidate’s team for as little as two hours and help win a close race.",
+ },
+];
+
+const SHIFT = [
+ { title: "Check in", body: "Meet the campaign team and get a quick briefing." },
+ {
+ title: "Get your list",
+ body: "Names of supporters the campaign has already identified who haven’t voted yet.",
+ },
+ {
+ title: "Knock or call",
+ body: "Remind them to vote and make sure they know where their polling station is.",
+ },
+ {
+ title: "Check back in",
+ body: "Report back, and take another list if you have time to stay.",
+ },
+];
+
+const linkClass = "underline underline-offset-2 hover:text-accent";
+
+const FAQS: { question: string; answer: React.ReactNode }[] = [
+ {
+ question: "Do I need any experience?",
+ answer:
+ "No. Your coordinator will match you with a role that suits you, and the Zoom trainings cover everything you need to know.",
+ },
+ {
+ question: "How much time does it take?",
+ answer:
+ "As little as two hours on October 26. If you can give more, campaigns will gladly take it, but every shift counts.",
+ },
+ {
+ question: "I haven’t decided which candidate to support.",
+ answer: (
+ <>
+ That’s fine.{" "}
+
+ Take the voter survey
+ {" "}
+ to see which candidates line up with you, and your coordinator will
+ help you find a race where your help counts.
+ >
+ ),
+ },
+ {
+ question: "Will I still have time to vote?",
+ answer: (
+ <>
+ Yes. Polls are open 10 a.m. to 8 p.m., and your shift is
+ about two hours.{" "}
+
+ Here’s how to vote in Toronto.
+
+ >
+ ),
+ },
+ {
+ question: "Can I help before Election Day?",
+ answer:
+ "Yes. Tell your coordinator when they call and they can connect you with your campaign sooner.",
+ },
+];
+
+const h2Class =
+ "font-sans font-medium leading-[1.05] tracking-[-0.03em] text-[clamp(1.9rem,3.2vw,2.5rem)] max-w-[24ch] text-balance";
+const ledeClass =
+ "font-serif text-[1.15rem] leading-[1.5] max-w-[58ch] text-text-secondary";
+
+export default function VolunteerPage() {
+ const now = new Date();
+
+ return (
+
+
+
+ {/* ── How Election Day volunteering works ──────────────── */}
+
+
+
+ How Election Day volunteering works
+
+
+ Elections are won in the last mile.
+
+
+ Campaigns have spent months finding the people who support them.
+ On Election Day, volunteers go back to those supporters and make
+ sure they actually vote. It’s the most direct way to turn
+ what you believe into a result.
+
+
+
+
+ You won’t be debating strangers. You’ll be reminding
+ people who already support your candidate to get to the polls.
+
+ Sign up and a volunteer coordinator will call to match you with
+ a candidate who shares your values.
+
+
+
+
+
+ {CONSENT}
+
+
+ Sign-ups are handled by our volunteer partner,{" "}
+
+ CJPAC
+
+ , a non-partisan organization that coordinates Election Day
+ volunteers and does not endorse any party or candidate.
+
+ >
+ ),
+ },
+ {
+ title: "Watch for your training link",
+ body: (
+
+ Short Zoom sessions run {TRAINING_DATES}. The link is in your
+ confirmation email.
+
+ ),
+ },
+ {
+ title: "Who will you bring?",
+ body: (
+ <>
+
+ Election Day is better with people you know. Ask two or three
+ friends, family members or colleagues to join you.
+
+
+ >
+ ),
+ },
+ {
+ title: "Make your own plan to vote",
+ body: (
+ <>
+
+ Polls are open {ELECTION_DAY.hoursLabel} on October 26. Fit
+ your vote around your shift.
+
+
+
+
+ >
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
Build the Vote
+
+ You’re in. Thank you.
+
+
+ A volunteer coordinator will call you in the next few days to
+ match you with a candidate and a campaign. They may call from a
+ number you don’t recognize.
+
- “We shall not err for want of boldness.”
+ “Whatever our errors are otherwise, we shall not err for want of
+ boldness.”
— Sir Wilfrid Laurier
diff --git a/src/lib/customerio.ts b/src/lib/customerio.ts
new file mode 100644
index 00000000..9236c634
--- /dev/null
+++ b/src/lib/customerio.ts
@@ -0,0 +1,66 @@
+// Identifies people to Customer.io through the Track API's v2 entity endpoint.
+//
+// Authenticates as HTTP Basic with the workspace's site ID and Track API key.
+// US workspaces post to track.customer.io, EU ones to track-eu.customer.io;
+// set CUSTOMERIO_REGION=eu for the latter.
+
+const ENTITY_URL = "https://track.customer.io/api/v2/entity";
+const EU_ENTITY_URL = "https://track-eu.customer.io/api/v2/entity";
+
+export type CustomerioAttributes = Record<
+ string,
+ string | number | boolean | undefined
+>;
+
+/**
+ * Creates or updates the person with this email. Returns false without
+ * sending when the credentials aren't configured, so callers can treat it as
+ * best-effort.
+ *
+ * Identified by email rather than an ID: York Factory keys the profiles it
+ * creates by subscriber ID, which we don't have here, and an email identifier
+ * finds that same profile instead of creating a second one.
+ */
+export async function identifyToCustomerio(
+ email: string,
+ attributes: CustomerioAttributes,
+): Promise {
+ const siteId = process.env.CUSTOMERIO_SITE_ID;
+ const apiKey = process.env.CUSTOMERIO_API_KEY;
+ if (!siteId || !apiKey) return false;
+
+ const url =
+ process.env.CUSTOMERIO_REGION?.toLowerCase() === "eu"
+ ? EU_ENTITY_URL
+ : ENTITY_URL;
+
+ // Blank attributes are dropped rather than sent, so an omitted field never
+ // clears what Customer.io already has for this person.
+ const present = Object.fromEntries(
+ Object.entries({ email, ...attributes }).filter(
+ ([, v]) => v !== undefined && v !== "",
+ ),
+ );
+
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Basic ${Buffer.from(`${siteId}:${apiKey}`).toString("base64")}`,
+ },
+ body: JSON.stringify({
+ type: "person",
+ identifiers: { email },
+ action: "identify",
+ attributes: present,
+ }),
+ cache: "no-store",
+ });
+
+ if (!res.ok) {
+ throw new Error(
+ `Customer.io identify failed: ${res.status} ${(await res.text()).slice(0, 300)}`,
+ );
+ }
+ return true;
+}
diff --git a/src/proxy.ts b/src/proxy.ts
index addef4b2..1c48621c 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -8,14 +8,20 @@ import {
refreshAccessToken,
setSessionCookies,
} from "@/lib/oauth";
+import {
+ EMAIL_COOKIE,
+ EMAIL_PARAMS,
+ STEP_2_PATH,
+} from "@/app/toronto/volunteer/build-the-vote";
const FLAG = "dashboard";
// Next 16 allows a single proxy (formerly "middleware") entrypoint, so this file
-// owns three unrelated concerns, dispatched by path:
+// owns four unrelated concerns, dispatched by path:
// - /dashboard/* → gate the section behind the `dashboard` PostHog flag
// - /memos/*, /posts/*, /builders/* → serve markdown to LLM agents (.md suffix or Accept header)
// - /memos/*, /api/auth/me → silently renew an expired OAuth session
+// - /toronto/volunteer/step-2 → move the volunteer's email out of the URL
export const config = {
matcher: [
"/memos/:path*",
@@ -25,6 +31,8 @@ export const config = {
"/api/auth/me",
"/dashboard",
"/dashboard/:path*",
+ "/toronto/volunteer/step-2",
+ "/toronto/volunteer/step-2/",
],
};
@@ -35,6 +43,10 @@ const MARKDOWN_CONTENT = /^\/(memos|polls|posts|builders)\/([^/]+?)(\.md)?$/;
export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
+ if (pathname.replace(/\/$/, "") === STEP_2_PATH) {
+ return stripVolunteerEmail(req);
+ }
+
if (pathname === "/dashboard" || pathname.startsWith("/dashboard/")) {
return gateDashboard(req);
}
@@ -48,6 +60,32 @@ export async function proxy(req: NextRequest) {
return NextResponse.next();
}
+// CJPAC's step 1 form sends volunteers to step 2 with their email in the query
+// string, so step 2's hidden email field can tie the two submissions together.
+// Left there, every analytics tag and pixel in the root layout would record it
+// with the page URL. So before any HTML is served, the email moves into a
+// short-lived cookie scoped to this one page, and the browser is sent to the
+// same URL without it. Other parameters (utm_*) survive the hop.
+function stripVolunteerEmail(req: NextRequest): NextResponse {
+ const url = req.nextUrl.clone();
+ const email = EMAIL_PARAMS.map((p) => url.searchParams.get(p)).find(Boolean);
+ if (!email) return NextResponse.next();
+
+ for (const p of EMAIL_PARAMS) url.searchParams.delete(p);
+ const response = NextResponse.redirect(url, 303);
+ response.cookies.set(EMAIL_COOKIE, email, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ path: STEP_2_PATH,
+ maxAge: 60 * 30,
+ });
+ // The redirect itself carries the email in its request URL; keep it out of
+ // any shared cache.
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+}
+
// Rewrites both public markdown shapes to the internal /md route:
// /memos/foo.md → /md/memos/foo
// /memos/foo + `Accept: text/markdown` header → /md/memos/foo