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 ( +
+ {hidden && } + {status === "loading" && ( +

Loading the form…

+ )} + {status === "failed" && ( +

+ The form didn’t load.{" "} + + Open it in a new tab + + . +

+ )} + + ); +} + +/** 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 ( +
+
+ {/* ── Hero ─────────────────────────────────────────────── */} +
+
+

+ Build the Vote · {ELECTION.voteDayLabel} +

+

+ The Toronto you know is possible doesn’t{" "} + vote itself in. +

+

+ Big issues that matter are all on the ballot. Give two hours on + Election Day to help a candidate who shares your values win + their race. +

+
+ +

+ Takes about a minute. No experience needed. +

+
+
+
+ +

+ Polls are open {ELECTION_DAY.hoursLabel} on{" "} + {ELECTION.voteDayLabel}. +

+
+
+ + {/* ── What's at stake ──────────────────────────────────── */} +
+
+

What’s at stake

+

+ What the next council will decide. +

+

+ Some of the questions facing whoever wins on October 26. +

+
+
+ {ISSUES.map((issue, i) => ( +
+

+ {String(i + 1).padStart(2, "0")} +

+

+ {issue.title} +

+

+ {issue.body} +

+
+ ))} +
+
+ +
+
+ + {/* ── Turnout and margins ──────────────────────────────── */} +
+
+

+ Fewer than 3 in 10 +

+

+ eligible Torontonians voted in the last municipal election. +

+

+ When turnout is this low, a handful of votes decides who wins. +

+

+ Every one of these 2022 races could have gone the other way. +

+
+
+ {MARGINS.map((m, i) => ( +
0 + ? "border-t md:border-t-0 md:border-l border-border-light" + : "" + }`} + > +

+ {m.votes} + + {m.votes === 1 ? "vote" : "votes"} + +

+

+ decided the{" "} + {m.race}{" "} + {m.detail} +

+
+ ))} +
+
+ + {/* ── 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. +

+
+
+ + {/* ── Three steps ──────────────────────────────────────── */} +
+
+

Three simple steps

+

Here’s how it works.

+
+
    + {STEPS.map((step, i) => ( +
  1. 0 + ? "border-t md:border-t-0 md:border-l border-border-light" + : "" + }`} + > +

    + {String(i + 1).padStart(2, "0")} +

    +

    + {step.title} +

    +

    + {step.body} +

    +
  2. + ))} +
+
+ +
+
+ + {/* ── A typical shift ──────────────────────────────────── */} +
+
+

+ What a typical shift looks like +

+

+ About two hours, as part of a team. +

+

You won’t be on your own.

+
+
    + {SHIFT.map((step) => ( +
  1. +

    + {step.title} +

    +

    + {step.body} +

    +
  2. + ))} +
+
+
+

+ Free training before Election Day +

+

+ Short Zoom sessions on canvassing and get-out-the-vote basics + on {TRAINING_DATES}. The link arrives by email when you sign + up. +

+
+
+
+ + {/* ── Sign-up ──────────────────────────────────────────── */} +
+
+

Build the Vote

+

+ Can Toronto count on you on October 26? +

+

+ 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. +

+
+
+ + {/* ── FAQ ──────────────────────────────────────────────── */} +
+
+

Questions

+
+
+ {FAQS.map((faq) => ( +
+

+ {faq.question} +

+

+ {faq.answer} +

+
+ ))} +
+
+ + {/* ── Closing (Toronto-blue band, full bleed) ──────────── */} +
+

+ “Whatever our errors are otherwise, we shall not err for want of + boldness.” +
— Sir Wilfrid Laurier +

+

+ Two hours. A close race. Your call. +

+ + Sign up to volunteer + +
+

+ Build the Vote is a Build Canada – Toronto volunteer + program. Volunteers choose the candidates they support. +

+

+ Turnout and race margins are from official City of Toronto 2022 + municipal election results. +

+
+
+
+
+ ); +} diff --git a/src/app/toronto/volunteer/step-2/page.tsx b/src/app/toronto/volunteer/step-2/page.tsx new file mode 100644 index 00000000..fef94fc1 --- /dev/null +++ b/src/app/toronto/volunteer/step-2/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { cookies } from "next/headers"; +import { ELECTION } from "../../vote/2026/key-dates"; +import { FormstackForm } from "../FormstackForm"; +import { SignupTracker } from "../SignupTracker"; +import { + CJPAC_URL, + EMAIL_COOKIE, + STEP_2, + THANKS_PATH, +} from "../build-the-vote"; + +export const metadata: Metadata = { + title: "A few more questions", + robots: { index: false, follow: false }, +}; + +export default async function VolunteerStep2Page() { + // Put here by src/proxy.ts, which took it off the URL step 1 redirected to. + const email = (await cookies()).get(EMAIL_COOKIE)?.value ?? ""; + + return ( +
+ +
+
+
+ +

Step 2 of 2

+

+ You’re signed up. Thank you. +

+

+ Three quick questions help your coordinator find the right race + for you. It takes under a minute. +

+
+
+ +

+ Sign-ups are handled by our volunteer partner,{" "} + + CJPAC + + , a non-partisan organization that coordinates Election Day + volunteers. +

+ + Skip this step + +
+
+
+
+ ); +} diff --git a/src/app/toronto/volunteer/thanks/InviteFriends.tsx b/src/app/toronto/volunteer/thanks/InviteFriends.tsx new file mode 100644 index 00000000..4d434345 --- /dev/null +++ b/src/app/toronto/volunteer/thanks/InviteFriends.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, useSyncExternalStore } from "react"; +import posthog from "posthog-js"; +import { Button } from "@/components/ui/button"; + +const noSubscription = () => () => {}; + +/** + * The "who will you bring?" step: a ready-to-send message with the sign-up + * link, plus the quickest ways to pass it on. The native share sheet only + * appears where the browser has one. The server snapshot says "no", so + * hydration matches and the button is added on the client. + */ +export function InviteFriends({ message }: { message: string }) { + const [copied, setCopied] = useState<"idle" | "copied" | "failed">("idle"); + const canShare = useSyncExternalStore( + noSubscription, + () => typeof navigator.share === "function", + () => false, + ); + + const track = (method: string) => + posthog.capture("volunteer_invite_shared", { method }); + + const copy = async () => { + track("copy"); + try { + await navigator.clipboard.writeText(message); + setCopied("copied"); + } catch { + setCopied("failed"); + } + }; + + const share = () => { + track("native"); + navigator.share({ text: message }).catch(() => {}); + }; + + return ( +
+

+ {message} +

+ +

+ {copied === "copied" && "Copied. Paste it into a text or group chat."} + {copied === "failed" && "Select the message above to copy it."} +

+
+ ); +} diff --git a/src/app/toronto/volunteer/thanks/page.tsx b/src/app/toronto/volunteer/thanks/page.tsx new file mode 100644 index 00000000..a63c9eb2 --- /dev/null +++ b/src/app/toronto/volunteer/thanks/page.tsx @@ -0,0 +1,156 @@ +import type { Metadata } from "next"; +import { Button } from "@/components/ui/button"; +import { getSiteConfig } from "@/lib/api"; +import { ELECTION, ELECTION_DAY, HOW_TO_VOTE_PATH } from "../../vote/2026/key-dates"; +import { TRAINING_DATES, VOLUNTEER_PATH } from "../build-the-vote"; +import { InviteFriends } from "./InviteFriends"; + +export const metadata: Metadata = { + title: "You’re in", + robots: { index: false, follow: false }, +}; + +const EVENT_TITLE = "Volunteer on Election Day (Build the Vote)"; +const EVENT_DETAILS = + "Your volunteer coordinator will confirm where and when. Polls are open 10 a.m. to 8 p.m."; + +/* An all-day event: the shift's time and place come from the coordinator's + call, so the calendar holds the day rather than guessing at hours. */ +const eventStart = ELECTION.electionDateIso.replaceAll("-", ""); +const eventEnd = String(Number(eventStart) + 1); + +const GOOGLE_CALENDAR_URL = + "https://calendar.google.com/calendar/render?" + + new URLSearchParams({ + action: "TEMPLATE", + text: EVENT_TITLE, + dates: `${eventStart}/${eventEnd}`, + details: EVENT_DETAILS, + }).toString(); + +const ICS_URL = + "data:text/calendar;charset=utf-8," + + encodeURIComponent( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Build Canada//Build the Vote//EN", + "CALSCALE:GREGORIAN", + "BEGIN:VEVENT", + `UID:build-the-vote-${eventStart}@buildcanada.com`, + "DTSTAMP:20261015T000000Z", + `DTSTART;VALUE=DATE:${eventStart}`, + `DTEND;VALUE=DATE:${eventEnd}`, + `SUMMARY:${EVENT_TITLE}`, + `DESCRIPTION:${EVENT_DETAILS.replace(/,/g, "\\,")}`, + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"), + ); + +export default function VolunteerThanksPage() { + const { siteUrl } = getSiteConfig(); + // Tagged so sign-ups from friends' invitations show up as their own source. + const inviteUrl = `${siteUrl}${VOLUNTEER_PATH}?utm_source=volunteer_invite&utm_medium=referral&utm_campaign=build_the_vote`; + const inviteMessage = `I'm volunteering in Toronto on Election Day, October 26, with Build Canada's Build the Vote. Two hours can help a candidate who shares our values win a close race. Join me: ${inviteUrl}`; + + const steps: { title: string; body: React.ReactNode }[] = [ + { + title: "Save the date", + body: ( + <> +

+ {ELECTION.voteDayLabel}. Your coordinator will confirm where and + when. +

+
+ + + Apple / Outlook + +
+ + ), + }, + { + 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. +

+
+ +
    + {steps.map((step, i) => ( +
  1. +
    +

    + {String(i + 1).padStart(2, "0")} +

    +

    + {step.title} +

    +
    +
    {step.body}
    +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/toronto/vote/get-involved/page.tsx b/src/app/toronto/vote/get-involved/page.tsx index 22cc8981..ea56bc29 100644 --- a/src/app/toronto/vote/get-involved/page.tsx +++ b/src/app/toronto/vote/get-involved/page.tsx @@ -135,7 +135,8 @@ export default function GetInvolvedPage() { {/* ── Closing CTA (Toronto-blue band, full bleed) ──────── */}

- “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() {
{/* ── Hero ─────────────────────────────────────────────── */}
-
+

Build the Vote · {ELECTION.voteDayLabel}

@@ -172,14 +172,14 @@ export default function VolunteerPage() { > Sign up to volunteer -

+

Takes about a minute. No experience needed.

-
+
-

+

Polls are open {ELECTION_DAY.hoursLabel} on{" "} {ELECTION.voteDayLabel}.

@@ -188,8 +188,8 @@ export default function VolunteerPage() { {/* ── What's at stake ──────────────────────────────────── */}
-
-

What’s at stake

+
+

What’s at stake

What the next council will decide.

@@ -201,21 +201,21 @@ export default function VolunteerPage() { {ISSUES.map((issue, i) => (
-

+

{String(i + 1).padStart(2, "0")}

-

+

{issue.title}

-

+

{issue.body}

))}
-
+
@@ -224,11 +224,11 @@ export default function VolunteerPage() { {/* ── Turnout and margins ──────────────────────────────── */}
-
+

Fewer than 3 in 10

-

+

eligible Torontonians voted in the last municipal election.

@@ -242,7 +242,7 @@ export default function VolunteerPage() { {MARGINS.map((m, i) => (
0 ? "border-t md:border-t-0 md:border-l border-border-light" : "" @@ -254,7 +254,7 @@ export default function VolunteerPage() { {m.votes === 1 ? "vote" : "votes"}

-

+

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 ──────────────────────────────────────── */}

-
-

Three simple steps

+
+

Three simple steps

Here’s how it works.

    {STEPS.map((step, i) => (
  1. 0 ? "border-t md:border-t-0 md:border-l border-border-light" : "" }`} > -

    +

    {String(i + 1).padStart(2, "0")}

    -

    +

    {step.title}

    -

    +

    {step.body}

  2. ))}
-
+
@@ -325,8 +325,8 @@ export default function VolunteerPage() { {/* ── A typical shift ──────────────────────────────────── */}
-
-

+

+

What a typical shift looks like

@@ -335,26 +335,29 @@ export default function VolunteerPage() {

You won’t be on your own.

    - {SHIFT.map((step) => ( + {SHIFT.map((step, i) => (
  1. -

    +

    + {String(i + 1).padStart(2, "0")} +

    +

    {step.title}

    -

    +

    {step.body}

  2. ))}
-
-
+
+

Free training before Election Day

-

+

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.

-
{/* ── Closing (Toronto-blue band, full bleed) ──────────── */} -
+

“Whatever our errors are otherwise, we shall not err for want of boldness.” @@ -434,7 +437,7 @@ export default function VolunteerPage() { > Sign up to volunteer -

+

Build the Vote is a Build Canada – Toronto volunteer program. Volunteers choose the candidates they support. diff --git a/src/app/toronto/volunteer/step-2/page.tsx b/src/app/toronto/volunteer/step-2/page.tsx index fef94fc1..c6f45e15 100644 --- a/src/app/toronto/volunteer/step-2/page.tsx +++ b/src/app/toronto/volunteer/step-2/page.tsx @@ -44,7 +44,7 @@ export default async function VolunteerStep2Page() { slug={STEP_2.slug} fill={{ [STEP_2.fields.email]: email }} /> -

+

Sign-ups are handled by our volunteer partner,{" "} ; /** - * Upserts a person's traits. Returns false without sending when no key is - * configured (local development), so callers can treat it as best-effort. + * 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. * - * 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. + * 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, - traits: CustomerioTraits, + attributes: CustomerioAttributes, ): Promise { - const key = process.env.CUSTOMERIO_API_KEY; - if (!key) return false; + 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_IDENTIFY_URL - : IDENTIFY_URL; + ? EU_ENTITY_URL + : ENTITY_URL; - // Blank traits are dropped rather than sent, so an omitted field never + // 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, ...traits }).filter( + Object.entries({ email, ...attributes }).filter( ([, v]) => v !== undefined && v !== "", ), ); @@ -44,9 +46,14 @@ export async function identifyToCustomerio( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`, + Authorization: `Basic ${Buffer.from(`${siteId}:${apiKey}`).toString("base64")}`, }, - body: JSON.stringify({ userId: email, traits: present }), + body: JSON.stringify({ + type: "person", + identifiers: { email }, + action: "identify", + attributes: present, + }), cache: "no-store", }); From 271595ef34314ecdc0c69f5b08316f4427ad4654 Mon Sep 17 00:00:00 2001 From: Brendan Samek Date: Thu, 15 Oct 2026 21:20:20 -0600 Subject: [PATCH 6/6] Centre the volunteer pages' content and keep Formstack from leaving the site Content now sits in a ~1080px centred column on wide screens, like the original design, with full-width backgrounds and rules. Formstack posts into a hidden iframe, and once it has the submission we send the volunteer to our own step 2 and thanks pages, so the flow no longer depends on the redirects set in CJPAC's Formstack (which currently point at CJPAC's hosted step 2). Co-Authored-By: Claude Opus 5.5 --- src/app/toronto/volunteer/FormstackForm.tsx | 37 ++++++++++++++- src/app/toronto/volunteer/SignupTracker.tsx | 3 ++ .../toronto/volunteer/VolunteerSignupForm.tsx | 19 +++++++- src/app/toronto/volunteer/gutter.ts | 24 ++++++++++ src/app/toronto/volunteer/page.tsx | 45 ++++++++++--------- .../toronto/volunteer/step-2/Step2Form.tsx | 16 +++++++ src/app/toronto/volunteer/step-2/page.tsx | 13 +++--- src/app/toronto/volunteer/thanks/page.tsx | 5 ++- 8 files changed, 127 insertions(+), 35 deletions(-) create mode 100644 src/app/toronto/volunteer/gutter.ts create mode 100644 src/app/toronto/volunteer/step-2/Step2Form.tsx diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx index ede36037..4e61cfaa 100644 --- a/src/app/toronto/volunteer/FormstackForm.tsx +++ b/src/app/toronto/volunteer/FormstackForm.tsx @@ -23,6 +23,15 @@ const FORMSTACK_ORIGIN = "https://cjpac.formstack.com"; * `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, @@ -30,6 +39,7 @@ export function FormstackForm({ check = [], hide = [], onSubmit, + onSubmitted, }: { /** the form's Formstack slug, e.g. "buildthevote_step1" */ slug: string; @@ -41,6 +51,8 @@ export function FormstackForm({ 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">( @@ -52,13 +64,23 @@ export function FormstackForm({ 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) || !form.checkValidity()) return; - onSubmit?.(new FormData(form)); + 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(() => { @@ -122,6 +144,17 @@ export function FormstackForm({

)}
+ {onSubmitted && ( +