Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ MONGO_URI=
# secret — set one of these in every deployed environment.
NEXTAUTH_SECRET=
# AUTH_SECRET is accepted as an alias.

# Customer.io Track API credentials, used to identify Build the Vote
# volunteers (src/lib/customerio.ts). Same workspace York Factory identifies
# subscribers to. Leave unset locally unless you mean to write to that
# workspace; the identify is then skipped. CUSTOMERIO_REGION=eu for an EU
# workspace.
CUSTOMERIO_SITE_ID=
CUSTOMERIO_API_KEY=
CUSTOMERIO_REGION=
49 changes: 49 additions & 0 deletions src/app/api/volunteer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";

import { identifyToCustomerio } from "@/lib/customerio";
import { normalizePostalCode } from "@/lib/elections/postal-code";

// Build the Vote sign-ups, identified to Customer.io. The sign-up itself goes
// to CJPAC's Formstack; this copies who signed up into our own Customer.io so
// we can follow up. Deliberately not York Factory: these aren't newsletter
// subscribers created by our own form.

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, first_name, last_name, city, postal_code } = body;

if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email) || email.length > 254) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
);
}

const str = (v: unknown, max: number) =>
typeof v === "string" ? v.trim().slice(0, max) : undefined;
const firstName = str(first_name, 100);
const lastName = str(last_name, 100);

const identified = await identifyToCustomerio(email.trim().toLowerCase(), {
first_name: firstName,
last_name: lastName,
name: [firstName, lastName].filter(Boolean).join(" "),
city: str(city, 100),
postal_code: normalizePostalCode(postal_code),
toronto_2026_build_the_vote_volunteer: true,
// A Unix timestamp, so date-based segments can use it.
toronto_2026_build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
});

// `identified` is false when Customer.io isn't configured (local dev).
return NextResponse.json({ success: true, identified });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
3 changes: 3 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 22 additions & 20 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,28 +57,30 @@ export default function RootLayout({
{PUBLICATION_FEEDS.map(({ file, title }) => (
<link key={file} rel="alternate" type="application/rss+xml" title={title} href={`/feeds/${file}`} />
))}
</head>
<Script id="gtag-init" strategy="beforeInteractive">
{`
(function () {
var gaId = ${JSON.stringify(GA_ID || "")};
var adsId = ${JSON.stringify(GOOGLE_ADS_ID)};
var productionHost = ['buildcanada.com', 'www.buildcanada.com'].includes(window.location.hostname);
if (!gaId && !productionHost) return;
{/* Inside <head>, not loose in <html>: React can't place a <script>
there, which warns about script order and breaks hydration. */}
<Script id="gtag-init" strategy="beforeInteractive">
{`
(function () {
var gaId = ${JSON.stringify(GA_ID || "")};
var adsId = ${JSON.stringify(GOOGLE_ADS_ID)};
var productionHost = ['buildcanada.com', 'www.buildcanada.com'].includes(window.location.hostname);
if (!gaId && !productionHost) return;

window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
window.gtag('js', new Date());
if (productionHost) window.gtag('config', adsId);
if (gaId) window.gtag('config', gaId);
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
window.gtag('js', new Date());
if (productionHost) window.gtag('config', adsId);
if (gaId) window.gtag('config', gaId);

var tag = document.createElement('script');
tag.async = true;
tag.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId || adsId);
document.head.appendChild(tag);
})();
`}
</Script>
var tag = document.createElement('script');
tag.async = true;
tag.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId || adsId);
document.head.appendChild(tag);
})();
`}
</Script>
</head>
<body className="antialiased bg-[#E0E0E0] p-[10px]">
<ThemeShell>
<div className="fixed top-0 left-0 right-0 h-[10px] bg-bg z-40" />
Expand Down
8 changes: 8 additions & 0 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,6 +107,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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(),
Expand Down
Loading
Loading