diff --git a/.env.local.example b/.env.local.example index 9b81a401..84a50064 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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= diff --git a/src/app/api/volunteer/route.ts b/src/app/api/volunteer/route.ts new file mode 100644 index 00000000..8b7d88fb --- /dev/null +++ b/src/app/api/volunteer/route.ts @@ -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 }); + } +} 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/layout.tsx b/src/app/layout.tsx index 67b265bc..d04bf8ea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -57,28 +57,30 @@ export default function RootLayout({ {PUBLICATION_FEEDS.map(({ file, title }) => ( ))} - - + var tag = document.createElement('script'); + tag.async = true; + tag.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(gaId || adsId); + document.head.appendChild(tag); + })(); + `} + +
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..4e61cfaa --- /dev/null +++ b/src/app/toronto/volunteer/FormstackForm.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useEffect, useEffectEvent, 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. + * + * `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, + fill = {}, + check = [], + hide = [], + onSubmit, + onSubmitted, +}: { + /** 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[]; + /** 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">( + "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(","); + + 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)) 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(() => { + 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; + 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 + + . +

+ )} +
+ {onSubmitted && ( +