Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/app/api/elections/pledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DEFAULT_ELECTION_SLUG,
getElection,
isSupportedElection,
SUPPORTED_ELECTIONS,
} from "@/lib/elections/registry";
import { forwardedHubspotContext } from "@/lib/hubspot-context";

Expand All @@ -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$/;
Expand All @@ -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<string, unknown>,
req: NextRequest,
): Promise<NextResponse> {
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();
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/app/elections/brampton/2026/pledge/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};
Expand Down
34 changes: 24 additions & 10 deletions src/app/elections/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
},
};
Expand Down Expand Up @@ -98,19 +99,32 @@ export default async function ElectionsPage() {
const elections = await getActiveElections();

return (
<div className="mx-[10px] my-[10px] border border-border-light bg-bg overflow-x-clip text-dark">
<div className="border border-border-light bg-bg overflow-x-clip text-dark">
{/* ── Hero ─────────────────────────────────────────────── */}
<section className="px-6 py-14 md:px-14 md:py-16 border-b-2 border-dark">
<p className="type-label text-accent mb-5">Elections</p>
<h1 className="font-sans font-medium leading-[0.98] tracking-[-0.04em] text-[clamp(3rem,7vw,5.75rem)] max-w-[16ch] text-balance mb-7">
Every race we&rsquo;re tracking
Canada belongs to those that show up.
</h1>
<h1 className="font-sans font-medium leading-[0.98] tracking-[-0.04em] text-[clamp(3rem,7vw,5.75rem)] max-w-[16ch] text-balance mb-7">
Pledge to Vote.
</h1>
<p className="font-serif text-[clamp(1.15rem,1.6vw,1.4rem)] leading-[1.5] max-w-[62ch]">
Elections decide what Canada will build next. Or if we build at all.
</p>
<p className="font-serif text-[clamp(1.15rem,1.6vw,1.4rem)] leading-[1.5] max-w-[62ch]">
Elections are where the country decides what it will build next.
Build&nbsp;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&rsquo;s running, and find where and how you can vote. Pledge to
vote.
</p>
<PledgeButton
election="broad"
source="elections-index"
className="group/btn mt-9 inline-flex items-center gap-3 type-button text-bg bg-dark px-5 py-4 transition-colors hover:bg-black cursor-pointer"
>
Pledge to Vote
<ArrowRight className="size-3.5 shrink-0 transition-transform group-hover/btn:translate-x-0.5" />
</PledgeButton>
</section>

{/* ── Active elections ─────────────────────────────────── */}
Expand Down
4 changes: 2 additions & 2 deletions src/app/toronto/elections/2026/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ export default async function Toronto2026ElectionPage() {
<div className="px-6 py-12 md:px-14 md:py-14 border-t-2 md:border-t-0 md:border-l border-border-light bg-bg-alt flex flex-col justify-center">
<p className="type-label text-accent mb-3.5">Ready to vote?</p>
<p className="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6">
Put your name on the record. Pledging takes ten seconds — and it&rsquo;s
the first step to showing up on election day.
Pledging takes ten seconds — and it&rsquo;s the first step to
showing up on election day.
</p>
<PledgeButton
source="election-ready-to-vote"
Expand Down
4 changes: 2 additions & 2 deletions src/app/toronto/elections/2026/pledge/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export async function generateMetadata({
const name = await resolvePledgeName("toronto-2026", slug, n);
return {
title: `${name} pledged to vote — Toronto 2026`,
description: `${name} is on the record for Toronto's 2026 municipal election, Monday, October 26. Will you be?`,
description: `${name} pledged to vote in Toronto's 2026 municipal election, Monday, October 26. Will you?`,
openGraph: {
title: `${name} pledged to vote — Toronto 2026 | Build Canada`,
description: `${name} pledged to vote in Toronto's 2026 municipal election on October 26, 2026. Join them on the record.`,
description: `${name} pledged to vote in Toronto's 2026 municipal election on October 26, 2026. Join them — pledge to vote.`,
type: "website",
},
};
Expand Down
55 changes: 43 additions & 12 deletions src/components/elections/PledgeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ import { DEFAULT_ELECTION_SLUG, getElection } from "@/lib/elections/registry";
Works for any election in the registry: `election` picks which one, and the
copy, the redirect targets and the share URL follow from it. Residency is
judged upstream against that election's jurisdiction, so a Brampton pledge
is checked against Brampton. */
is checked against Brampton.

`election="broad"` is the Canada-wide variant for pages not tied to one
election (e.g. /elections): the copy names no city, and the API matches the
pledge to whichever supported election the pledger lives in — landing on
that election's share page — or, when none matches, subscribes them and
confirms in the modal. */
export function PledgeButton({
className,
children,
Expand All @@ -26,20 +32,24 @@ export function PledgeButton({
}: {
className?: string;
children: React.ReactNode;
/** York Factory election slug, e.g. "brampton-2026" */
/** York Factory election slug, e.g. "brampton-2026", or "broad" for a
* Canada-wide pledge aimed at no single election */
election?: string;
/** e.g. "ward-5" for ward-scoped pledges; defaults to the whole city */
region?: string;
source?: string;
}) {
const config = getElection(election);
const config = election === "broad" ? null : getElection(election);
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [postalCode, setPostalCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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();
Expand All @@ -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(),
}),
});
Expand All @@ -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.",
Expand All @@ -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);
Expand All @@ -120,7 +141,7 @@ export function PledgeButton({
if (open)
posthog.capture("pledge_modal_opened", {
source,
election: config.slug,
election: config?.slug ?? "broad",
});
}}
>
Expand All @@ -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."}
</Dialog.Description>
</div>
{subscribedOnly ? (
<p className="type-body">
Thanks — we&rsquo;ll let you know when we&rsquo;re covering an
election where you live.
</p>
) : (
<>
{/* 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 */}
Expand Down Expand Up @@ -206,6 +235,8 @@ export function PledgeButton({
</Button>
{error && <p className="type-label-sm text-auburn-800">{error}</p>}
</form>
</>
)}
<Dialog.Close
aria-label="Close dialog"
className="absolute w-11 h-11 flex items-center justify-center text-charcoal-600 hover:text-charcoal-1000 hover:bg-charcoal-200/30 rounded-sm transition-colors cursor-pointer"
Expand Down
Loading