From 9f3ac1ab1c811f243134b361d19af2027804776b Mon Sep 17 00:00:00 2001 From: xrendan Date: Wed, 29 Jul 2026 13:12:11 -0600 Subject: [PATCH] Add a broad Pledge to Vote flow and CTA on /elections The elections index now carries a "Pledge to Vote" button, but unlike the city pages it pledges broadly rather than defaulting to Toronto: the API tries each supported election (residency judged upstream) and keeps the first that records a pledge, landing the pledger on that election's share page. Pledgers outside every supported jurisdiction are subscribed and confirmed in the modal instead of bounced. Also rewrites the hero copy and drops the "on the record" phrasing from pledge copy site-wide in favour of concrete language. --- src/app/api/elections/pledge/route.ts | 83 +++++++++++++++++++ .../brampton/2026/pledge/[slug]/page.tsx | 4 +- src/app/elections/page.tsx | 34 +++++--- src/app/toronto/elections/2026/page.tsx | 4 +- .../elections/2026/pledge/[slug]/page.tsx | 4 +- src/components/elections/PledgeButton.tsx | 55 +++++++++--- 6 files changed, 156 insertions(+), 28 deletions(-) diff --git a/src/app/api/elections/pledge/route.ts b/src/app/api/elections/pledge/route.ts index 024d06e..a9587d1 100644 --- a/src/app/api/elections/pledge/route.ts +++ b/src/app/api/elections/pledge/route.ts @@ -5,6 +5,7 @@ import { DEFAULT_ELECTION_SLUG, getElection, isSupportedElection, + SUPPORTED_ELECTIONS, } from "@/lib/elections/registry"; import { forwardedHubspotContext } from "@/lib/hubspot-context"; @@ -17,6 +18,13 @@ import { forwardedHubspotContext } from "@/lib/hubspot-context"; // registry before it reaches the API, so a client can't aim this at an // arbitrary slug. Region is e.g. "ward-5" for ward-scoped pledge buttons and // defaults to the election's jurisdiction ("toronto", "brampton", …). +// +// `election: "broad"` is the Canada-wide pledge from pages aimed at no single +// election. York Factory only records pledges per election, and it is the +// authority on who lives where — so we submit to each supported election in +// turn and keep the first that records a pledge. A pledger outside every +// supported jurisdiction is still subscribed by the first attempt; that comes +// back as success (election: null) rather than the outside-region bounce. const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; @@ -31,6 +39,77 @@ function normalizePostalCode(raw: unknown): string | undefined { return `${compact.slice(0, 3)} ${compact.slice(3)}`; } +// The broad flow: try every supported election until one records a pledge. +// Jurisdictions are disjoint, so at most one will. Upstream upserts the same +// subscriber on each attempt, so the extra calls are idempotent. +async function broadPledge( + body: Record, + req: NextRequest, +): Promise { + const { email, name, postal_code } = body; + let firstError: { status: number; error: string } | null = null; + let subscribedName: string | null = null; + let subscribed = false; + + for (const config of Object.values(SUPPORTED_ELECTIONS)) { + const res = await fetch(`${API_URL}/elections/${config.slug}/pledges`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + name: typeof name === "string" ? name.slice(0, 100) : undefined, + region: config.jurisdictionSlug, + postal_code: normalizePostalCode(postal_code), + ...forwardedHubspotContext(body, req), + }), + cache: "no-store", + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + firstError ??= { + status: res.status, + error: errorData.errors?.[0] || "Pledge failed", + }; + continue; + } + + const data = await res.json(); + + // Outside this jurisdiction (or the postal code couldn't be judged + // against it) — the subscriber was still kept; try the next election. + if (data.outside_region || data.outside_toronto) { + subscribed = true; + subscribedName ??= data.name ?? null; + continue; + } + + return NextResponse.json({ + success: true, + election: config.slug, + region: data.region, + regionCount: data.region_count, + shareToken: data.share_token ?? null, + name: data.name ?? null, + }); + } + + // No supported election matched. If any attempt at least subscribed them, + // that's a broad-pledge success; otherwise report the first upstream error. + if (subscribed) { + return NextResponse.json({ + success: true, + election: null, + subscribed: true, + name: subscribedName, + }); + } + return NextResponse.json( + { error: firstError?.error ?? "Pledge failed" }, + { status: firstError?.status ?? 502 }, + ); +} + export async function POST(req: NextRequest) { try { const body = await req.json(); @@ -48,6 +127,10 @@ export async function POST(req: NextRequest) { ); } + if (election === "broad") { + return broadPledge(body, req); + } + // An omitted election means the Toronto flow, which shipped before this // was parameterized. A named one we don't support is a client error, not // something to quietly record as a Toronto pledge. diff --git a/src/app/elections/brampton/2026/pledge/[slug]/page.tsx b/src/app/elections/brampton/2026/pledge/[slug]/page.tsx index ff6ed53..2b34ca8 100644 --- a/src/app/elections/brampton/2026/pledge/[slug]/page.tsx +++ b/src/app/elections/brampton/2026/pledge/[slug]/page.tsx @@ -16,10 +16,10 @@ export async function generateMetadata({ const name = await resolvePledgeName("brampton-2026", slug, n); return { title: `${name} pledged to vote — Brampton 2026`, - description: `${name} is on the record for Brampton's 2026 municipal election, Monday, October 26. Will you be?`, + description: `${name} pledged to vote in Brampton's 2026 municipal election, Monday, October 26. Will you?`, openGraph: { title: `${name} pledged to vote — Brampton 2026 | Build Canada`, - description: `${name} pledged to vote in Brampton's 2026 municipal election on October 26, 2026. Join them on the record.`, + description: `${name} pledged to vote in Brampton's 2026 municipal election on October 26, 2026. Join them — pledge to vote.`, type: "website", }, }; diff --git a/src/app/elections/page.tsx b/src/app/elections/page.tsx index b429d0e..28e8775 100644 --- a/src/app/elections/page.tsx +++ b/src/app/elections/page.tsx @@ -1,17 +1,18 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowRight } from "lucide-react"; +import { PledgeButton } from "@/components/elections/PledgeButton"; import { getActiveElections, type ActiveElection } from "./data"; export const metadata: Metadata = { title: "Elections", description: - "Every election Build Canada is tracking — who is running, what they intend to build, and when the polls open.", + "Every election is an opportunity for Canadians to vote for to build. We are working to track every race in Canada, help you see who's running, and find where and how you can vote. Pledge to vote.", alternates: { canonical: "/elections" }, openGraph: { - title: "Elections — Build Canada", + title: "Elections — Pledge to Vote | Build Canada", description: - "Every election Build Canada is tracking — who is running, what they intend to build, and when the polls open.", + "Every election is an opportunity for Canadians to vote for growth. We track every race in Canada, help you see who's running, and find where and how you can vote. Pledge to vote.", type: "website", }, }; @@ -98,19 +99,32 @@ export default async function ElectionsPage() { const elections = await getActiveElections(); return ( -
+
{/* ── Hero ─────────────────────────────────────────────── */}
-

Elections

- Every race we’re tracking + Canada belongs to those that show up.

+

+ Pledge to Vote. +

+

+ Elections decide what Canada will build next. Or if we build at all. +

- Elections are where the country decides what it will build next. - Build Canada tracks the races that matter: who is running, what - they intend to build, and when the polls open. Pick an election below - to see the full field. + Every election is an opportunity for Canadians to vote for to build. + We are working to track every race in Canada, help you see + who’s running, and find where and how you can vote. Pledge to + vote.

+ + Pledge to Vote + +
{/* ── Active elections ─────────────────────────────────── */} diff --git a/src/app/toronto/elections/2026/page.tsx b/src/app/toronto/elections/2026/page.tsx index d51764c..8a9f340 100644 --- a/src/app/toronto/elections/2026/page.tsx +++ b/src/app/toronto/elections/2026/page.tsx @@ -77,8 +77,8 @@ export default async function Toronto2026ElectionPage() {

Ready to vote?

- Put your name on the record. Pledging takes ten seconds — and it’s - the first step to showing up on election day. + Pledging takes ten seconds — and it’s the first step to + showing up on election day.

(null); + // Broad pledges with no matching election confirm here instead of + // redirecting to an election share page. + const [subscribedOnly, setSubscribedOnly] = useState(false); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -66,9 +76,9 @@ export function PledgeButton({ body: JSON.stringify({ email, name, - region: region ?? config.jurisdictionSlug, + region: region ?? config?.jurisdictionSlug, postal_code: postalCode, - election: config.slug, + election: config?.slug ?? "broad", ...hubspotPageContext(), }), }); @@ -84,8 +94,9 @@ export function PledgeButton({ // and let them fix it, since they may well live here — or they're outside // the jurisdiction, in which case they're subscribed but not pledged, and // the landing page explains and invites them to explore. Keep the button - // disabled while we navigate. - if (data.outsideRegion) { + // disabled while we navigate. (Broad pledges never take this branch — + // the API turns "outside every supported election" into success below.) + if (data.outsideRegion && config) { if (data.unverifiedPostalCode) { setError( "We couldn't verify that postal code. Please check it and try again.", @@ -101,10 +112,20 @@ export function PledgeButton({ return; } - posthog.capture("pledged_to_vote", { source, election: config.slug }); + // A broad pledge that matched a supported election redirects to that + // election's share page; one that matched none confirms in place. + const matched = config ?? (data.election ? getElection(data.election) : null); + if (!matched) { + posthog.capture("pledged_to_vote", { source, election: "broad" }); + setSubscribedOnly(true); + setLoading(false); + return; + } + + posthog.capture("pledged_to_vote", { source, election: matched.slug }); // keep the button disabled while we navigate to the shared page; // prefer the server's record (canonical name + unguessable token) - router.push(pledgeSharePath(config, data.name || name, data.shareToken)); + router.push(pledgeSharePath(matched, data.name || name, data.shareToken)); } catch { setError("Network error. Please try again."); setLoading(false); @@ -120,7 +141,7 @@ export function PledgeButton({ if (open) posthog.capture("pledge_modal_opened", { source, - election: config.slug, + election: config?.slug ?? "broad", }); }} > @@ -142,10 +163,18 @@ export function PledgeButton({ className="type-body" style={{ marginBottom: "clamp(0.375rem, 1.5vw, 0.75rem)" }} > - {config.cityLabel} votes {config.voteDayLabel}. Put your name on - the record. + {config + ? `${config.cityLabel} votes ${config.voteDayLabel}. Add your name — it takes ten seconds.` + : "Elections are won by the people who show up. Add your name — it takes ten seconds."}
+ {subscribedOnly ? ( +

+ Thanks — we’ll let you know when we’re covering an + election where you live. +

+ ) : ( + <> {/* id/name attributes double as autofill hints and give HubSpot's collected-forms feature (if enabled) a sane form name and field → contact-property mapping instead of CSS-class guesses */} @@ -206,6 +235,8 @@ export function PledgeButton({ {error &&

{error}

} + + )}