diff --git a/README.md b/README.md index eb347e3..3d07a0f 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Recommended: |----------|---------| | `NEXT_PUBLIC_POSTHOG_TOKEN` | PostHog analytics + client-side exception capture (`error.tsx` boundaries report here). | | `NEXT_PUBLIC_POSTHOG_HOST` | PostHog UI host. Defaults to `https://us.i.posthog.com`. | -| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics. Loader is conditional — omit to disable. | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics. Omit to disable Analytics; the Google Ads tag still loads on `buildcanada.com` and `www.buildcanada.com` to track completed signups and voter pledges. Ads does not load on other hosts. | ## Deployment notes diff --git a/instrumentation-client.ts b/instrumentation-client.ts index ee2e7d3..a612c37 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -1,10 +1,65 @@ import posthog from "posthog-js"; +const IGNORED_EXTENSION_ERROR = + "Invalid call to runtime.sendMessage(). Tab not found."; + +const EXTENSION_FRAME_PREFIXES = [ + "chrome-extension://", + "moz-extension://", + "safari-extension:", + "safari-web-extension:", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isKnownExtensionError(exception: unknown): boolean { + if (!isRecord(exception) || exception.value !== IGNORED_EXTENSION_ERROR) { + return false; + } + + const stacktrace = exception.stacktrace; + if (!isRecord(stacktrace) || !Array.isArray(stacktrace.frames)) { + return false; + } + + return stacktrace.frames.some((frame) => { + if (!isRecord(frame) || typeof frame.filename !== "string") { + return false; + } + + const filename = frame.filename; + return EXTENSION_FRAME_PREFIXES.some((prefix) => + filename.startsWith(prefix), + ); + }); +} + if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_POSTHOG_TOKEN) { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, { api_host: "/ph", ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", defaults: "2026-01-30", capture_exceptions: true, + before_send: (event) => { + if (event?.event !== "$exception") { + return event; + } + + const exceptionList = event.properties.$exception_list; + const rootException = Array.isArray(exceptionList) + ? exceptionList[0] + : undefined; + + // Do not filter on the message alone: require extension provenance from + // the root exception's own stack. Stackless or app-originated lookalikes + // are deliberately retained to avoid hiding a real application error. + if (isKnownExtensionError(rootException)) { + return null; + } + + return event; + }, }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 702ac18..67b265b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,6 +14,7 @@ import { XPixel } from "@/components/XPixel"; import { MetaPixel } from "@/components/MetaPixel"; const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; +const GOOGLE_ADS_ID = "AW-17738155749"; export const metadata: Metadata = { title: { @@ -57,22 +58,27 @@ export default function RootLayout({ ))} - {GA_ID && ( - <> - - - )} +
diff --git a/src/app/voteTO/route.ts b/src/app/voteTO/route.ts new file mode 100644 index 0000000..2bc864b --- /dev/null +++ b/src/app/voteTO/route.ts @@ -0,0 +1,23 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const DESTINATION = "https://www.buildcanada.com/toronto/vote/2026/"; +const CAMPAIGN_PARAMS = { + utm_source: "direct_mail", + utm_medium: "offline", + utm_campaign: "voteTO", +} as const; + +export function GET(request: NextRequest) { + const destination = new URL(DESTINATION); + destination.search = request.nextUrl.search; + + // Keep every supplied value, including custom tracking fields and repeated + // parameters. Add attribution only when the mail URL did not supply it. + for (const [key, value] of Object.entries(CAMPAIGN_PARAMS)) { + if (!destination.searchParams.has(key)) { + destination.searchParams.append(key, value); + } + } + + return NextResponse.redirect(destination, 307); +} diff --git a/src/components/elections/PledgeButton.tsx b/src/components/elections/PledgeButton.tsx index cc944c6..9f8510a 100644 --- a/src/components/elections/PledgeButton.tsx +++ b/src/components/elections/PledgeButton.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState, type FormEvent } from "react"; import posthog from "posthog-js"; import { Button } from "@/components/ui/button"; +import { reportConversion } from "@/lib/conversions"; import { hubspotPageContext } from "@/lib/hubspot-context"; import { pledgeSharePath } from "@/lib/elections/pledge-share"; import { DEFAULT_ELECTION_SLUG, getElection } from "@/lib/elections/registry"; @@ -108,7 +109,10 @@ export function PledgeButton({ return; } - posthog.capture("pledged_to_vote", { source, election: config.slug }); + await reportConversion("voterPledge", { + source, + election: config.slug, + }); trackXEvent("pledgedToVote", email); // keep the button disabled while we navigate to the shared page; // prefer the server's record (canonical name + unguessable token) diff --git a/src/components/subscribe/ui/SubscribeForm.tsx b/src/components/subscribe/ui/SubscribeForm.tsx index 67e0a47..1c88091 100644 --- a/src/components/subscribe/ui/SubscribeForm.tsx +++ b/src/components/subscribe/ui/SubscribeForm.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from "react"; import posthog from "posthog-js"; import { Button } from "@/components/ui/button"; +import { reportConversion } from "@/lib/conversions"; import { hubspotPageContext } from "@/lib/hubspot-context"; import { useSubscribeStore, type SubscribeSource } from "../store"; @@ -69,7 +70,7 @@ export function SubscribeForm({ return; } posthog.identify(email, { email }); - posthog.capture("subscribed", { source }); + void reportConversion("signup", { source }); setSubscribed(); onSuccess?.(); } catch { diff --git a/src/lib/conversions.ts b/src/lib/conversions.ts new file mode 100644 index 0000000..5295db8 --- /dev/null +++ b/src/lib/conversions.ts @@ -0,0 +1,92 @@ +"use client"; + +import posthog from "posthog-js"; + +type ConversionProperties = Record< + string, + string | number | boolean | null | undefined +>; + +const conversions = { + signup: { + posthogEvent: "subscribed", + googleAdsDestination: "AW-17738155749/W-XUCMCNx-AcEOWNm4pC", + }, + voterPledge: { + posthogEvent: "pledged_to_vote", + googleAdsDestination: "AW-17738155749/xgobCL2Nx-AcEOWNm4pC", + }, +} as const; + +export type Conversion = keyof typeof conversions; + +declare global { + interface Window { + gtag?: (...args: unknown[]) => void; + dataLayer?: IArguments[]; + } +} + +/** + * Reports a completed conversion to every configured analytics/ad platform. + * + * The promise gives navigations a brief chance to let providers send their + * event. It never lets an analytics failure interrupt the user's successful + * action. + */ +export function reportConversion( + conversion: Conversion, + properties: ConversionProperties = {}, +): Promise { + const definition = conversions[conversion]; + + try { + posthog.capture(definition.posthogEvent, properties); + } catch { + // Analytics must not turn a successful conversion into a user-facing error. + } + + return reportGoogleAdsConversion(definition.googleAdsDestination); +} + +function reportGoogleAdsConversion(destination: string): Promise { + if ( + typeof window === "undefined" || + !["buildcanada.com", "www.buildcanada.com"].includes( + window.location.hostname, + ) + ) { + return Promise.resolve(); + } + + // The tag loader runs asynchronously. Queue a completed conversion even if + // it finishes before the bootstrap script installs gtag. + window.dataLayer = window.dataLayer || []; + const gtag = window.gtag || function () { + // gtag.js consumes the arguments object from each queued command. + // eslint-disable-next-line prefer-rest-params + window.dataLayer?.push(arguments); + }; + window.gtag = gtag; + + return new Promise((resolve) => { + const done = () => { + clearTimeout(timeout); + resolve(); + }; + + // Do not hold up a client-side navigation if an ad blocker prevents the + // Google callback from firing. + const timeout = setTimeout(resolve, 1_000); + + try { + gtag("event", "conversion", { + send_to: destination, + event_callback: done, + event_timeout: 1_000, + }); + } catch { + done(); + } + }); +}