From 437370fa6ee88544eccddcb8b15cb8fcdc2a812f Mon Sep 17 00:00:00 2001
From: Brendan Samek
Date: Thu, 15 Oct 2026 12:55:08 -0600
Subject: [PATCH 1/6] Add Build the Vote volunteer pages
Election Day volunteer sign-up with CJPAC at /toronto/volunteer, with
/step-2 and /thanks beneath it, rebuilt from CJPAC's preview in the
site's own layout and components.
The sign-up forms stay in CJPAC's Formstack account. FormstackForm
embeds them, fills the partner "UTM Source" field, and hides the email
opt-in and CJPAC's consent copy in favour of the consent line agreed
with CJPAC, which adds Build Canada email.
Step 1 redirects to step 2 with the volunteer's email in the query
string. The proxy moves it into a page-scoped, short-lived httpOnly
cookie and redirects to the clean URL before any HTML is served, so no
analytics tag or pixel records it.
Also uses the full Laurier quote on the closing bands.
Co-Authored-By: Claude Opus 5.5
---
src/app/globals.css | 3 +
src/app/sitemap.ts | 8 +
src/app/toronto/volunteer/FormstackForm.tsx | 239 +++++++++
src/app/toronto/volunteer/SignupTracker.tsx | 17 +
src/app/toronto/volunteer/build-the-vote.ts | 57 +++
src/app/toronto/volunteer/formstack.css | 132 +++++
src/app/toronto/volunteer/opengraph-image.tsx | 17 +
src/app/toronto/volunteer/page.tsx | 462 ++++++++++++++++++
src/app/toronto/volunteer/step-2/page.tsx | 71 +++
.../volunteer/thanks/InviteFriends.tsx | 78 +++
src/app/toronto/volunteer/thanks/page.tsx | 156 ++++++
src/app/toronto/vote/get-involved/page.tsx | 3 +-
src/proxy.ts | 40 +-
13 files changed, 1281 insertions(+), 2 deletions(-)
create mode 100644 src/app/toronto/volunteer/FormstackForm.tsx
create mode 100644 src/app/toronto/volunteer/SignupTracker.tsx
create mode 100644 src/app/toronto/volunteer/build-the-vote.ts
create mode 100644 src/app/toronto/volunteer/formstack.css
create mode 100644 src/app/toronto/volunteer/opengraph-image.tsx
create mode 100644 src/app/toronto/volunteer/page.tsx
create mode 100644 src/app/toronto/volunteer/step-2/page.tsx
create mode 100644 src/app/toronto/volunteer/thanks/InviteFriends.tsx
create mode 100644 src/app/toronto/volunteer/thanks/page.tsx
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/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..7eb4feb6
--- /dev/null
+++ b/src/app/toronto/volunteer/FormstackForm.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { useEffect, 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.
+ */
+export function FormstackForm({
+ slug,
+ fill = {},
+ check = [],
+ hide = [],
+}: {
+ /** 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[];
+}) {
+ 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(",");
+
+ 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..5c94c243
--- /dev/null
+++ b/src/app/toronto/volunteer/SignupTracker.tsx
@@ -0,0 +1,17 @@
+"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(() => {
+ posthog.capture("volunteer_signed_up", { program: "build_the_vote" });
+ }, []);
+
+ return null;
+}
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..a8295635
--- /dev/null
+++ b/src/app/toronto/volunteer/build-the-vote.ts
@@ -0,0 +1,57 @@
+// 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: {
+ /** 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..8a5dc0d0
--- /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.9rem !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/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..2699f6c1
--- /dev/null
+++ b/src/app/toronto/volunteer/page.tsx
@@ -0,0 +1,462 @@
+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 { FormstackForm } from "./FormstackForm";
+import {
+ CJPAC_URL,
+ CONSENT,
+ PARTNER_SOURCE,
+ STEP_1,
+ TRAINING_DATES,
+ VOLUNTEER_PATH,
+} from "./build-the-vote";
+
+/* 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.1rem] 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/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
From 6d8c889cc3122f60e25a611e07a655a41bdfd940 Mon Sep 17 00:00:00 2001
From: Brendan Samek
Date: Thu, 15 Oct 2026 18:36:15 -0600
Subject: [PATCH 2/6] Move the gtag init script inside
React can't place a beforeInteractive
+ var tag = document.createElement('script');
+ tag.async = true;
+ tag.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId || adsId);
+ document.head.appendChild(tag);
+ })();
+ `}
+
+
From 41e97064f051d56d59890a08b30cbc7c0ff547e9 Mon Sep 17 00:00:00 2001
From: Brendan Samek
Date: Thu, 15 Oct 2026 18:39:29 -0600
Subject: [PATCH 3/6] Copy Build the Vote sign-ups to our subscriber list and
identify them
Step 1 now also posts the volunteer's name, email and postal code to
/api/subscribe (placement build_the_vote) on a valid submit, which York
Factory identifies to Customer.io, and identifies them in PostHog.
Co-Authored-By: Claude Opus 5.5
---
src/app/toronto/volunteer/FormstackForm.tsx | 25 +++++++-
.../toronto/volunteer/VolunteerSignupForm.tsx | 57 +++++++++++++++++++
src/app/toronto/volunteer/build-the-vote.ts | 9 +++
src/app/toronto/volunteer/page.tsx | 15 +----
4 files changed, 92 insertions(+), 14 deletions(-)
create mode 100644 src/app/toronto/volunteer/VolunteerSignupForm.tsx
diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx
index 7eb4feb6..ede36037 100644
--- a/src/app/toronto/volunteer/FormstackForm.tsx
+++ b/src/app/toronto/volunteer/FormstackForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
const FORMSTACK_ORIGIN = "https://cjpac.formstack.com";
@@ -19,12 +19,17 @@ const FORMSTACK_ORIGIN = "https://cjpac.formstack.com";
* 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.
*/
export function FormstackForm({
slug,
fill = {},
check = [],
hide = [],
+ onSubmit,
}: {
/** the form's Formstack slug, e.g. "buildthevote_step1" */
slug: string;
@@ -34,6 +39,8 @@ export function FormstackForm({
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;
}) {
const containerRef = useRef(null);
const [status, setStatus] = useState<"loading" | "ready" | "failed">(
@@ -45,6 +52,22 @@ export function FormstackForm({
const fillKey = JSON.stringify(fill);
const checkKey = check.join(",");
+ // 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) || !form.checkValidity()) return;
+ 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;
diff --git a/src/app/toronto/volunteer/VolunteerSignupForm.tsx b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
new file mode 100644
index 00000000..3a9d39e1
--- /dev/null
+++ b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import posthog from "posthog-js";
+import { hubspotPageContext } from "@/lib/hubspot-context";
+import { FormstackForm } from "./FormstackForm";
+import { PARTNER_SOURCE, STEP_1, SUBSCRIBER_PLACEMENT } from "./build-the-vote";
+
+const { fields } = STEP_1;
+
+/**
+ * Step 1 of the sign-up. Formstack sends the volunteer to CJPAC; we copy them
+ * into our own subscriber list too, which is how they reach Customer.io
+ * (York Factory identifies every new subscriber there). The consent line
+ * under the form covers Build Canada email, so a sign-up is a subscribe.
+ *
+ * Formstack may leave the page as soon as its submit handler runs, so the copy
+ * goes as a keepalive request that outlives the navigation, and nothing waits
+ * on it. Phone stays with CJPAC: our subscriber list has nowhere to put it.
+ */
+export function VolunteerSignupForm() {
+ 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;
+
+ posthog.identify(email, { email });
+
+ fetch("/api/subscribe", {
+ 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`),
+ postal_code: value(`${fields.address}-zip`),
+ source: SUBSCRIBER_PLACEMENT,
+ ...hubspotPageContext(),
+ }),
+ }).catch(() => {
+ // Best-effort: CJPAC still has the sign-up.
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/toronto/volunteer/build-the-vote.ts b/src/app/toronto/volunteer/build-the-vote.ts
index a8295635..65371b2d 100644
--- a/src/app/toronto/volunteer/build-the-vote.ts
+++ b/src/app/toronto/volunteer/build-the-vote.ts
@@ -19,6 +19,11 @@ 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",
@@ -40,6 +45,10 @@ export const STEP_2 = {
},
} as const;
+/** Sent with each step 1 sign-up we copy into our own subscriber list (and
+ * from there to Customer.io), so these subscribers can be told apart. */
+export const SUBSCRIBER_PLACEMENT = "build_the_vote";
+
/** 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;
diff --git a/src/app/toronto/volunteer/page.tsx b/src/app/toronto/volunteer/page.tsx
index 2699f6c1..6aa7dbda 100644
--- a/src/app/toronto/volunteer/page.tsx
+++ b/src/app/toronto/volunteer/page.tsx
@@ -2,15 +2,13 @@ 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 { FormstackForm } from "./FormstackForm";
import {
CJPAC_URL,
CONSENT,
- PARTNER_SOURCE,
- STEP_1,
TRAINING_DATES,
VOLUNTEER_PATH,
} from "./build-the-vote";
+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
@@ -381,16 +379,7 @@ export default function VolunteerPage() {
-
+
{CONSENT}
From 1a6831286bac1515c0f3f8d3ddd4e54be80693f2 Mon Sep 17 00:00:00 2001
From: Brendan Samek
Date: Thu, 15 Oct 2026 18:56:24 -0600
Subject: [PATCH 4/6] Identify Build the Vote volunteers to Customer.io
directly
Replaces the copy into York Factory's subscriber list: step 1 now posts
to /api/volunteer, which identifies the volunteer to Customer.io's CDP
API (keyed by email, tagged build_the_vote_volunteer), alongside the
PostHog identify. Needs CUSTOMERIO_API_KEY in the deployment env.
Co-Authored-By: Claude Opus 5.5
---
.env.local.example | 7 +++
src/app/api/volunteer/route.ts | 48 +++++++++++++++
.../toronto/volunteer/VolunteerSignupForm.tsx | 21 +++----
src/app/toronto/volunteer/build-the-vote.ts | 4 --
src/lib/customerio.ts | 59 +++++++++++++++++++
5 files changed, 123 insertions(+), 16 deletions(-)
create mode 100644 src/app/api/volunteer/route.ts
create mode 100644 src/lib/customerio.ts
diff --git a/.env.local.example b/.env.local.example
index 9b81a401..e6c4d35c 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -41,3 +41,10 @@ MONGO_URI=
# secret — set one of these in every deployed environment.
NEXTAUTH_SECRET=
# AUTH_SECRET is accepted as an alias.
+
+# Customer.io CDP write key, used to identify Build the Vote volunteers
+# (src/lib/customerio.ts). Same workspace York Factory identifies subscribers
+# to. Leave unset locally so development never writes to production; the
+# identify is then skipped. CUSTOMERIO_REGION=eu for an EU workspace.
+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..747ad307
--- /dev/null
+++ b/src/app/api/volunteer/route.ts
@@ -0,0 +1,48 @@
+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);
+
+ 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),
+ build_the_vote_volunteer: true,
+ // A Unix timestamp, so date-based segments can use it.
+ build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ return NextResponse.json({ error: String(err) }, { status: 500 });
+ }
+}
diff --git a/src/app/toronto/volunteer/VolunteerSignupForm.tsx b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
index 3a9d39e1..1c9d3c25 100644
--- a/src/app/toronto/volunteer/VolunteerSignupForm.tsx
+++ b/src/app/toronto/volunteer/VolunteerSignupForm.tsx
@@ -1,21 +1,19 @@
"use client";
import posthog from "posthog-js";
-import { hubspotPageContext } from "@/lib/hubspot-context";
import { FormstackForm } from "./FormstackForm";
-import { PARTNER_SOURCE, STEP_1, SUBSCRIBER_PLACEMENT } from "./build-the-vote";
+import { PARTNER_SOURCE, STEP_1 } from "./build-the-vote";
const { fields } = STEP_1;
/**
- * Step 1 of the sign-up. Formstack sends the volunteer to CJPAC; we copy them
- * into our own subscriber list too, which is how they reach Customer.io
- * (York Factory identifies every new subscriber there). The consent line
- * under the form covers Build Canada email, so a sign-up is a subscribe.
+ * 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 copy
- * goes as a keepalive request that outlives the navigation, and nothing waits
- * on it. Phone stays with CJPAC: our subscriber list has nowhere to put it.
+ * 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.
*/
export function VolunteerSignupForm() {
const handleSubmit = (values: FormData) => {
@@ -28,7 +26,7 @@ export function VolunteerSignupForm() {
posthog.identify(email, { email });
- fetch("/api/subscribe", {
+ fetch("/api/volunteer", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
@@ -36,9 +34,8 @@ export function VolunteerSignupForm() {
email,
first_name: value(`${fields.name}-first`),
last_name: value(`${fields.name}-last`),
+ city: value(`${fields.address}-city`),
postal_code: value(`${fields.address}-zip`),
- source: SUBSCRIBER_PLACEMENT,
- ...hubspotPageContext(),
}),
}).catch(() => {
// Best-effort: CJPAC still has the sign-up.
diff --git a/src/app/toronto/volunteer/build-the-vote.ts b/src/app/toronto/volunteer/build-the-vote.ts
index 65371b2d..eff546df 100644
--- a/src/app/toronto/volunteer/build-the-vote.ts
+++ b/src/app/toronto/volunteer/build-the-vote.ts
@@ -45,10 +45,6 @@ export const STEP_2 = {
},
} as const;
-/** Sent with each step 1 sign-up we copy into our own subscriber list (and
- * from there to Customer.io), so these subscribers can be told apart. */
-export const SUBSCRIBER_PLACEMENT = "build_the_vote";
-
/** 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;
diff --git a/src/lib/customerio.ts b/src/lib/customerio.ts
new file mode 100644
index 00000000..443606e7
--- /dev/null
+++ b/src/lib/customerio.ts
@@ -0,0 +1,59 @@
+// Identifies people to Customer.io through the CDP identify endpoint, the same
+// API York Factory's CustomerioService uses for newsletter subscribers.
+//
+// The CDP write key authenticates as HTTP Basic with the key as the username
+// and an empty password. US workspaces post to cdp.customer.io, EU ones to
+// cdp-eu.customer.io; set CUSTOMERIO_REGION=eu for the latter.
+
+const IDENTIFY_URL = "https://cdp.customer.io/v1/identify";
+const EU_IDENTIFY_URL = "https://cdp-eu.customer.io/v1/identify";
+
+export type CustomerioTraits = Record<
+ string,
+ string | number | boolean | undefined
+>;
+
+/**
+ * Upserts a person's traits. Returns false without sending when no key is
+ * configured (local development), so callers can treat it as best-effort.
+ *
+ * Keyed by email: unlike York Factory we have no row id to key on, and an
+ * email-shaped userId matches the existing profile with that email.
+ */
+export async function identifyToCustomerio(
+ email: string,
+ traits: CustomerioTraits,
+): Promise {
+ const key = process.env.CUSTOMERIO_API_KEY;
+ if (!key) return false;
+
+ const url =
+ process.env.CUSTOMERIO_REGION?.toLowerCase() === "eu"
+ ? EU_IDENTIFY_URL
+ : IDENTIFY_URL;
+
+ // Blank traits 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, ...traits }).filter(
+ ([, v]) => v !== undefined && v !== "",
+ ),
+ );
+
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
+ },
+ body: JSON.stringify({ userId: email, traits: present }),
+ cache: "no-store",
+ });
+
+ if (!res.ok) {
+ throw new Error(
+ `Customer.io identify failed: ${res.status} ${(await res.text()).slice(0, 300)}`,
+ );
+ }
+ return true;
+}
From cb6da696e0c36951b7e068294ac94ba6d635b133 Mon Sep 17 00:00:00 2001
From: Brendan Samek
Date: Thu, 15 Oct 2026 21:14:57 -0600
Subject: [PATCH 5/6] Tighten the volunteer page's stacking and identify via
the Track API
Matches the demo's tighter vertical rhythm on phones (smaller step
numbers sat right above their titles, less padding between cards and
sections) and raises the smallest reading text to 17px.
Customer.io now goes through the Track API's v2 entity endpoint
(site ID + API key, identified by email) with the attributes
toronto_2026_build_the_vote_volunteer and
toronto_2026_build_the_vote_signed_up_at.
Co-Authored-By: Claude Opus 5.5
---
.env.local.example | 10 ++-
src/app/api/volunteer/route.ts | 9 ++-
src/app/toronto/volunteer/formstack.css | 2 +-
src/app/toronto/volunteer/page.tsx | 93 ++++++++++++-----------
src/app/toronto/volunteer/step-2/page.tsx | 2 +-
src/lib/customerio.ts | 49 +++++++-----
6 files changed, 89 insertions(+), 76 deletions(-)
diff --git a/.env.local.example b/.env.local.example
index e6c4d35c..84a50064 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -42,9 +42,11 @@ MONGO_URI=
NEXTAUTH_SECRET=
# AUTH_SECRET is accepted as an alias.
-# Customer.io CDP write key, used to identify Build the Vote volunteers
-# (src/lib/customerio.ts). Same workspace York Factory identifies subscribers
-# to. Leave unset locally so development never writes to production; the
-# identify is then skipped. CUSTOMERIO_REGION=eu for an EU workspace.
+# 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
index 747ad307..8b7d88fb 100644
--- a/src/app/api/volunteer/route.ts
+++ b/src/app/api/volunteer/route.ts
@@ -30,18 +30,19 @@ export async function POST(req: NextRequest) {
const firstName = str(first_name, 100);
const lastName = str(last_name, 100);
- await identifyToCustomerio(email.trim().toLowerCase(), {
+ 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),
- build_the_vote_volunteer: true,
+ toronto_2026_build_the_vote_volunteer: true,
// A Unix timestamp, so date-based segments can use it.
- build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
+ toronto_2026_build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
});
- return NextResponse.json({ success: true });
+ // `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/toronto/volunteer/formstack.css b/src/app/toronto/volunteer/formstack.css
index 8a5dc0d0..88b2d077 100644
--- a/src/app/toronto/volunteer/formstack.css
+++ b/src/app/toronto/volunteer/formstack.css
@@ -50,7 +50,7 @@
.formstack-embed .fsSupportingSubLabel,
.formstack-embed .fsSupporting {
font-family: var(--font-serif) !important;
- font-size: 0.9rem !important;
+ font-size: 0.95rem !important;
color: var(--color-text-secondary) !important;
margin-top: 0.35rem !important;
}
diff --git a/src/app/toronto/volunteer/page.tsx b/src/app/toronto/volunteer/page.tsx
index 6aa7dbda..6a97ce14 100644
--- a/src/app/toronto/volunteer/page.tsx
+++ b/src/app/toronto/volunteer/page.tsx
@@ -140,7 +140,7 @@ const FAQS: { question: string; answer: React.ReactNode }[] = [
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.1rem] leading-[1.5] max-w-[58ch] text-text-secondary";
+ "font-serif text-[1.15rem] leading-[1.5] max-w-[58ch] text-text-secondary";
export default function VolunteerPage() {
const now = new Date();
@@ -150,7 +150,7 @@ export default function VolunteerPage() {
decided the{" "}
{m.race}{" "}
{m.detail}
@@ -266,8 +266,8 @@ export default function VolunteerPage() {
{/* ── How Election Day volunteering works ──────────────── */}
-
-
+
+
How Election Day volunteering works
@@ -280,7 +280,7 @@ export default function VolunteerPage() {
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.
@@ -290,33 +290,33 @@ export default function VolunteerPage() {
{/* ── Three steps ──────────────────────────────────────── */}
-
Short Zoom sessions on canvassing and get-out-the-vote basics
on {TRAINING_DATES}. The link arrives by email when you sign
up.
@@ -368,7 +371,7 @@ export default function VolunteerPage() {
id="signup"
className="grid lg:grid-cols-[1fr_1.2fr] border-b-2 border-dark scroll-mt-24"
>
-
+
Build the Vote
Can Toronto count on you on October 26?
@@ -378,12 +381,12 @@ export default function VolunteerPage() {
a candidate who shares your values.
-
+
-
+
{CONSENT}
-
+
Sign-ups are handled by our volunteer partner,{" "}
-