diff --git a/.env.local.example b/.env.local.example index 84a5006..800198e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -50,3 +50,12 @@ NEXTAUTH_SECRET= CUSTOMERIO_SITE_ID= CUSTOMERIO_API_KEY= CUSTOMERIO_REGION= + +# Build the Vote sign-ups → the Google Sheet shared with CJPAC, written with +# the Sheets API as a service account (src/lib/google-sheet.ts). Share the +# Sheet with the service account's email as an Editor. The private key is the +# JSON key's private_key; literal \n escapes are fine. Required in production +# (sign-ups fail without it); leave unset locally to skip the Sheet. +GOOGLE_SERVICE_ACCOUNT_EMAIL= +GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY= +GOOGLE_SHEET_ID= diff --git a/package.json b/package.json index 0f50c32..a59d8cb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "seed:bills-admin": "tsx scripts/seed-bills-admin.ts", "eval:bills": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env src/app/bills/evals/run.ts", "test:feeds": "tsx --test src/lib/feeds.test.ts", + "test:sheet": "tsx --test src/lib/google-sheet.test.ts", "test:polls": "NODE_OPTIONS=--conditions=import tsx --test src/lib/charts/inline-chart.test.ts src/lib/polls/downloads.test.ts src/lib/polls/crosstabs.test.ts" }, "dependencies": { diff --git a/src/app/api/volunteer/details/route.ts b/src/app/api/volunteer/details/route.ts new file mode 100644 index 0000000..284badf --- /dev/null +++ b/src/app/api/volunteer/details/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + AVAILABILITY_OPTIONS, + EMAIL_COOKIE, + MAYORAL_OPTIONS, + SHEET_COLUMNS, + SHEET_TAB, +} from "@/app/toronto/volunteer/build-the-vote"; +import { sheetTimestamp, upsertSheetRow } from "@/lib/google-sheet"; + +// Build the Vote step 2: optional details, added to the volunteer's row in the +// Sheet shared with CJPAC. The row is found by the email step 1's response put +// in an httpOnly cookie, so the email is never in a URL or the page. These +// answers (including a mayoral preference) go to the Sheet only, not to +// Customer.io. + +export async function POST(req: NextRequest) { + try { + const email = req.cookies.get(EMAIL_COOKIE)?.value; + if (!email) { + return NextResponse.json({ error: "expired" }, { status: 401 }); + } + + const body = await req.json(); + const availability = Array.isArray(body.availability) + ? AVAILABILITY_OPTIONS.filter((o) => body.availability.includes(o)) + : []; + const mayoral = MAYORAL_OPTIONS.find((o) => o === body.mayoral) ?? ""; + const issues = + typeof body.issues === "string" ? body.issues.trim().slice(0, 2000) : ""; + + await upsertSheetRow({ + tab: SHEET_TAB, + columns: SHEET_COLUMNS, + email, + values: { + Availability: availability.join(", "), + "Mayoral candidate": mayoral, + Issues: issues, + "Details added at": sheetTimestamp(), + }, + }); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error(err); + return NextResponse.json( + { error: "Something went wrong. Please try again." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/volunteer/route.ts b/src/app/api/volunteer/route.ts index 8b7d88f..6408060 100644 --- a/src/app/api/volunteer/route.ts +++ b/src/app/api/volunteer/route.ts @@ -1,49 +1,115 @@ import { NextRequest, NextResponse } from "next/server"; +import { + DETAILS_API_PATH, + EMAIL_COOKIE, + PARTNER_SOURCE, + SHEET_COLUMNS, + SHEET_TAB, +} from "@/app/toronto/volunteer/build-the-vote"; import { identifyToCustomerio } from "@/lib/customerio"; import { normalizePostalCode } from "@/lib/elections/postal-code"; +import { sheetTimestamp, upsertSheetRow } from "@/lib/google-sheet"; -// 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. +// Build the Vote step 1. Writes the sign-up to the Google Sheet shared with +// CJPAC, which is how CJPAC receives it, then identifies the volunteer to +// Customer.io for our own follow-up. Deliberately not York Factory: these +// aren't newsletter subscribers created by our newsletter form. +// +// The Sheet write must succeed, or the volunteer is told to try again: a +// sign-up CJPAC never sees is lost. Customer.io is best-effort. The phone +// number goes to the Sheet only; CJPAC calls, we don't. + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 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 }); - } + // A field people never see; bots that fill in every input do. Answer as + // if it worked so they don't learn to skip it. + if (body.website) return NextResponse.json({ success: true }); + + const str = (v: unknown, max: number) => + typeof v === "string" ? v.trim().slice(0, max) : ""; + + const email = str(body.email, 254).toLowerCase(); + const firstName = str(body.first_name, 50); + const lastName = str(body.last_name, 50); + const phone = normalizePhone(body.phone); + const city = str(body.city, 100); + const postalCode = normalizePostalCode(body.postal_code); - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email) || email.length > 254) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 }, - ); + const checks: [boolean, string][] = [ + [Boolean(firstName && lastName), "Please enter your first and last name"], + [EMAIL_PATTERN.test(email), "Please enter a valid email address"], + [Boolean(phone), "Please enter a 10-digit phone number"], + [Boolean(city), "Please enter your city"], + [Boolean(postalCode), "Please enter a valid postal code (e.g. M5V 1A1)"], + ]; + const failed = checks.find(([ok]) => !ok); + if (failed) { + return NextResponse.json({ error: failed[1] }, { 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), + await upsertSheetRow({ + tab: SHEET_TAB, + columns: SHEET_COLUMNS, + email, + values: { + "Signed up at": sheetTimestamp(), + "First name": firstName, + "Last name": lastName, + Phone: phone, + City: city, + "Postal code": postalCode, + Source: str(body.utm_source, 100), + Medium: str(body.utm_medium, 100), + Campaign: str(body.utm_campaign, 100), + Partner: PARTNER_SOURCE, + }, }); - // `identified` is false when Customer.io isn't configured (local dev). - return NextResponse.json({ success: true, identified }); + try { + await identifyToCustomerio(email, { + first_name: firstName, + last_name: lastName, + name: `${firstName} ${lastName}`, + city, + postal_code: postalCode, + 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), + }); + } catch (err) { + console.error(err); + } + + // Lets step 2 add to this row without the email in a URL or the page. + const response = NextResponse.json({ success: true }); + response.cookies.set(EMAIL_COOKIE, email, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: DETAILS_API_PATH, + maxAge: 60 * 60 * 2, + }); + return response; } catch (err) { - return NextResponse.json({ error: String(err) }, { status: 500 }); + console.error(err); + return NextResponse.json( + { error: "Something went wrong. Please try again." }, + { status: 500 }, + ); } } + +/** "416-555-0123", "(416) 555 0123", "+1 416…" → "(416) 555-0123". North + * American numbers only; anything else returns "". */ +function normalizePhone(raw: unknown): string { + if (typeof raw !== "string") return ""; + let digits = raw.replace(/\D/g, ""); + if (digits.length === 11 && digits.startsWith("1")) digits = digits.slice(1); + if (digits.length !== 10) return ""; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} diff --git a/src/app/globals.css b/src/app/globals.css index 878b49f..bcdda7b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,8 +8,6 @@ /* 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; diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx deleted file mode 100644 index 4e61cfa..0000000 --- a/src/app/toronto/volunteer/FormstackForm.tsx +++ /dev/null @@ -1,295 +0,0 @@ -"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 && ( -