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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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;
},
});
}
38 changes: 22 additions & 16 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -57,22 +58,27 @@ export default function RootLayout({
<link key={file} rel="alternate" type="application/rss+xml" title={title} href={`/feeds/${file}`} />
))}
</head>
{GA_ID && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="gtag-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</>
)}
<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);

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>
<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
23 changes: 23 additions & 0 deletions src/app/voteTO/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 5 additions & 1 deletion src/components/elections/PledgeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/components/subscribe/ui/SubscribeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -69,7 +70,7 @@ export function SubscribeForm({
return;
}
posthog.identify(email, { email });
posthog.capture("subscribed", { source });
void reportConversion("signup", { source });
setSubscribed();
onSuccess?.();
} catch {
Expand Down
92 changes: 92 additions & 0 deletions src/lib/conversions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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();
}
});
}
Loading