Skip to content

Commit f8a946b

Browse files
committed
Setup Analytics
1 parent 2679893 commit f8a946b

8 files changed

Lines changed: 315 additions & 2 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ jobs:
4040
run: npm run build
4141
env:
4242
PAGES_BASE_PATH: ${{ steps.setup_pages.outputs.base_path }}
43+
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: ${{ vars.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID }}
44+
GOOGLE_SITE_VERIFICATION_ID: ${{ vars.GOOGLE_SITE_VERIFICATION_ID }}
4345

4446
- name: Upload artifact
4547
uses: actions/upload-pages-artifact@v5

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ If you change a submission field, update **all** of: the relevant issue template
4444
- Prettier config is inside `package.json` (`singleQuote`, `printWidth: 100`) plus three plugins: `organize-imports`, `packagejson`, `tailwindcss` — running `npm run format` will reorder imports and Tailwind classes.
4545
- Tailwind v4 (no `tailwind.config`); design tokens live in `src/app/globals.css`. UI primitives in `src/components/ui/` follow the shadcn/Radix pattern (see `components.json`).
4646
- Client components live where they're used; the shared `SubmissionsTable` is `'use client'` because of `nuqs` and `useState`. Tracker page entry points stay server components and pass JSON data down.
47+
48+
## Analytics (GDPR)
49+
50+
Google Analytics 4 is loaded under **Google Consent Mode v2** (`src/components/analytics/`, shared logic in `src/lib/consent.ts`). gtag boots with every consent category defaulted to `denied`; only `analytics_storage` upgrades to `granted` after the user clicks Accept in the `CookieConsent` banner, and the choice is persisted in `localStorage`. All of it is inert unless `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` (a GA4 `G-XXXXXXXX` id) is set at build time — provided in production via the `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` GitHub Actions repository variable (see `deploy.yml`). To test locally, set `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` in `.env.local` before `npm run dev`.
51+
52+
**Page views on in-app navigation.** The app navigates via `next/link` (soft navigation using the History API), so there is no full page reload between routes. Client-side navigations are counted by GA4's **Enhanced Measurement** ("Page changes based on browser history events", enabled on the property) — `page_view` fires "each time the page loads _or_ the browser history state is changed". There is deliberately **no** manual route-change tracking in the code; adding one would double-count every soft navigation. Keep the Enhanced Measurement option enabled on the GA4 property.

src/app/layout.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { CookieConsent } from '@/components/analytics/CookieConsent';
2+
import { CookieSettingsLink } from '@/components/analytics/CookieSettingsLink';
3+
import { GoogleAnalytics } from '@/components/analytics/GoogleAnalytics';
14
import { TooltipProvider } from '@/components/ui/tooltip';
25
import type { Metadata } from 'next';
36
import { Inter } from 'next/font/google';
@@ -11,6 +14,8 @@ const interSans = Inter({
1114
variable: '--font-inter-sans',
1215
});
1316

17+
const googleSiteVerificationId = process.env.GOOGLE_SITE_VERIFICATION_ID;
18+
1419
export const metadata: Metadata = {
1520
title: {
1621
template: '%s | Quantum Advantage Tracker',
@@ -21,12 +26,16 @@ export const metadata: Metadata = {
2126
icons: {
2227
icon: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">📐</text></svg>')}`,
2328
},
29+
...(googleSiteVerificationId && {
30+
verification: { google: googleSiteVerificationId },
31+
}),
2432
};
2533

2634
export default function RootLayout(props: LayoutProps<'/'>) {
2735
return (
2836
<html lang="en">
2937
<body className={`${interSans.variable} font-sans antialiased`}>
38+
<GoogleAnalytics />
3039
<NuqsAdapter>
3140
<TooltipProvider>
3241
<header>
@@ -43,9 +52,17 @@ export default function RootLayout(props: LayoutProps<'/'>) {
4352

4453
<main>{props.children}</main>
4554

46-
<footer className="px-6 py-6 text-center">
47-
<div>Quantum Advantage Tracker © 2026</div>
55+
<footer className="text-muted-foreground px-6 py-6 text-center text-sm">
56+
<div className="flex flex-col items-center justify-center gap-x-3 gap-y-1 sm:flex-row">
57+
<span>Quantum Advantage Tracker © 2026</span>
58+
<Link href="/legal/privacy" className="hover:text-foreground underline">
59+
Privacy &amp; Cookies
60+
</Link>
61+
<CookieSettingsLink />
62+
</div>
4863
</footer>
64+
65+
<CookieConsent />
4966
</TooltipProvider>
5067
</NuqsAdapter>
5168
</body>

src/app/legal/privacy/page.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Metadata } from 'next';
2+
3+
export const metadata: Metadata = {
4+
title: 'Privacy & Cookies',
5+
description:
6+
'How the Quantum Advantage Tracker uses analytics and cookies, and how you can control your consent.',
7+
};
8+
9+
export default function PrivacyPage() {
10+
return (
11+
<div className="mx-auto max-w-3xl px-6 py-10">
12+
<h1 className="text-2xl font-semibold">Privacy &amp; Cookies</h1>
13+
14+
<div className="text-muted-foreground mt-6 space-y-4 text-sm leading-relaxed">
15+
<p>
16+
The Quantum Advantage Tracker is a static website. We use Google Analytics 4 (GA4) to
17+
understand how visitors use the site so we can improve it. We do not sell your data or use
18+
it for advertising.
19+
</p>
20+
21+
<h2 className="text-foreground text-lg font-medium">Consent</h2>
22+
<p>
23+
Analytics are governed by Google Consent Mode v2. When you first visit, analytics storage
24+
is <strong>denied</strong> by default: no analytics cookies are set and the analytics
25+
service cannot identify you. Until you choose, the analytics service may still receive
26+
anonymous, aggregated signals that contain no cookies or identifiers. Full analytics —
27+
including the cookies that recognise returning visitors — is only enabled after you click{' '}
28+
<strong>Accept</strong> in the consent banner. If you click <strong>Reject</strong>, no
29+
analytics cookies are stored and no identifiers are used.
30+
</p>
31+
32+
<h2 className="text-foreground text-lg font-medium">What we collect</h2>
33+
<p>
34+
When analytics are enabled, the analytics service collects standard usage data such as
35+
pages visited, approximate location (derived from your IP address, which the analytics
36+
service does not log or store), device and browser type, and referral source. This data is
37+
processed by the analytics service on our behalf.
38+
</p>
39+
40+
<h2 className="text-foreground text-lg font-medium">Cookies</h2>
41+
<p>
42+
After you accept, the analytics service sets first-party analytics cookies to distinguish
43+
visitors and measure how the site is used. These are only created once consent is granted.
44+
</p>
45+
46+
<h2 className="text-foreground text-lg font-medium">Changing your choice</h2>
47+
<p>
48+
You can change or withdraw your consent at any time using the link in the site footer
49+
labeled <strong>Cookie settings</strong>. Withdrawing consent disables analytics and
50+
automatically removes the analytics cookies already stored on your device, so you are no
51+
longer identified. You can also clear the site&apos;s cookies in your browser at any time.
52+
</p>
53+
54+
<h2 className="text-foreground text-lg font-medium">Contact</h2>
55+
<p>
56+
Questions about this policy can be raised on our{' '}
57+
<a
58+
href="https://github.com/quantum-advantage-tracker/quantum-advantage-tracker.github.io"
59+
className="underline"
60+
target="_blank"
61+
rel="noopener noreferrer"
62+
>
63+
GitHub repository
64+
</a>
65+
.
66+
</p>
67+
</div>
68+
</div>
69+
);
70+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
import { Button } from '@/components/ui/button';
4+
import {
5+
clearAnalyticsCookies,
6+
CONSENT_CHANGE_EVENT,
7+
ConsentChoice,
8+
GA_ENABLED,
9+
getStoredConsent,
10+
OPEN_CONSENT_EVENT,
11+
setStoredConsent,
12+
updateGtagConsent,
13+
} from '@/lib/consent';
14+
import Link from 'next/link';
15+
import { useSyncExternalStore } from 'react';
16+
17+
// External store so banner visibility stays in sync with localStorage without a
18+
// hydration mismatch. Visible until a choice is stored, or when re-opened.
19+
let forceOpen = false;
20+
21+
function subscribe(onChange: () => void) {
22+
const reopen = () => {
23+
forceOpen = true;
24+
onChange();
25+
};
26+
window.addEventListener(OPEN_CONSENT_EVENT, reopen);
27+
window.addEventListener(CONSENT_CHANGE_EVENT, onChange);
28+
return () => {
29+
window.removeEventListener(OPEN_CONSENT_EVENT, reopen);
30+
window.removeEventListener(CONSENT_CHANGE_EVENT, onChange);
31+
};
32+
}
33+
34+
const getSnapshot = () => forceOpen || getStoredConsent() === null;
35+
// Keep the banner out of the prerendered static HTML.
36+
const getServerSnapshot = () => false;
37+
38+
// GDPR consent banner, re-openable from the footer "Cookie settings" link.
39+
export function CookieConsent() {
40+
const visible = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
41+
42+
if (!GA_ENABLED || !visible) return null;
43+
44+
function choose(choice: ConsentChoice) {
45+
forceOpen = false;
46+
updateGtagConsent(choice);
47+
// On grant we don't emit a page_view — Consent Mode records the page and GA
48+
// writes the `_ga` cookie on the next event. On denial, clear existing ones.
49+
if (choice === 'denied') clearAnalyticsCookies();
50+
setStoredConsent(choice);
51+
}
52+
53+
return (
54+
<div
55+
role="region"
56+
aria-label="Cookie consent"
57+
className="border-border bg-card text-card-foreground fixed inset-x-0 bottom-0 z-50 border-t shadow-lg"
58+
>
59+
<div className="mx-auto flex max-w-7xl flex-col items-start gap-4 px-6 py-4 md:flex-row md:items-center md:justify-between">
60+
<p className="text-muted-foreground text-sm">
61+
We use analytics to understand usage. Nothing is stored unless you accept — see our{' '}
62+
<Link href="/legal/privacy" className="text-foreground underline">
63+
Privacy &amp; Cookies
64+
</Link>{' '}
65+
page.
66+
</p>
67+
<div className="flex shrink-0 items-center gap-2">
68+
<Button variant="outline" onClick={() => choose('denied')}>
69+
Reject
70+
</Button>
71+
<Button onClick={() => choose('granted')}>Accept</Button>
72+
</div>
73+
</div>
74+
</div>
75+
);
76+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
3+
import { GA_ENABLED, OPEN_CONSENT_EVENT } from '@/lib/consent';
4+
5+
export function CookieSettingsLink() {
6+
if (!GA_ENABLED) return null;
7+
8+
return (
9+
<button
10+
type="button"
11+
className="hover:text-foreground cursor-pointer underline"
12+
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_CONSENT_EVENT))}
13+
>
14+
Cookie settings
15+
</button>
16+
);
17+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { CONSENT_STORAGE_KEY, GA_ENABLED, GA_ID } from '@/lib/consent';
2+
import Script from 'next/script';
3+
4+
// Loads GA4 under Consent Mode v2. The consent defaults are a plain inline
5+
// <script> (not next/script) so they run on parse — before gtag.js and before
6+
// hydration — reading the persisted choice synchronously so returning visitors
7+
// who accepted start `granted`. Renders nothing when GA is not configured.
8+
export function GoogleAnalytics() {
9+
if (!GA_ENABLED) return null;
10+
11+
return (
12+
<>
13+
<script
14+
id="ga-consent-default"
15+
dangerouslySetInnerHTML={{
16+
__html: `
17+
window.dataLayer = window.dataLayer || [];
18+
function gtag(){dataLayer.push(arguments);}
19+
window.gtag = gtag;
20+
(function () {
21+
var granted = false;
22+
try { granted = localStorage.getItem('${CONSENT_STORAGE_KEY}') === 'granted'; } catch (e) {}
23+
gtag('consent', 'default', {
24+
ad_storage: 'denied',
25+
ad_user_data: 'denied',
26+
ad_personalization: 'denied',
27+
analytics_storage: granted ? 'granted' : 'denied',
28+
wait_for_update: 500
29+
});
30+
})();
31+
gtag('js', new Date());
32+
// No route tracking: soft-navigation page_views come from GA4
33+
// Enhanced Measurement, so a manual hook would double-count them.
34+
gtag('config', '${GA_ID}');
35+
`,
36+
}}
37+
/>
38+
<Script
39+
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
40+
strategy="afterInteractive"
41+
/>
42+
</>
43+
);
44+
}

src/lib/consent.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Shared client-side consent logic for GDPR-compliant Google Analytics under
2+
// Consent Mode v2. No-op unless a GA4 Measurement ID is set at build time via
3+
// `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID`.
4+
5+
declare global {
6+
interface Window {
7+
dataLayer: unknown[];
8+
gtag: (...args: unknown[]) => void;
9+
}
10+
}
11+
12+
export const GA_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;
13+
export const GA_ENABLED = Boolean(GA_ID);
14+
15+
export const CONSENT_STORAGE_KEY = 'qat-analytics-consent';
16+
17+
// Fired on `window` when the stored choice changes / to re-open the banner.
18+
export const CONSENT_CHANGE_EVENT = 'qat-consent-change';
19+
export const OPEN_CONSENT_EVENT = 'qat-open-consent';
20+
21+
export type ConsentChoice = 'granted' | 'denied';
22+
23+
// Fallback for when localStorage is blocked: keeps the choice for the session.
24+
let memoryConsent: ConsentChoice | null = null;
25+
26+
export function getStoredConsent(): ConsentChoice | null {
27+
if (typeof window === 'undefined') return null;
28+
try {
29+
const value = window.localStorage.getItem(CONSENT_STORAGE_KEY);
30+
if (value === 'granted' || value === 'denied') return value;
31+
} catch {
32+
// Storage blocked — fall back to the in-memory choice.
33+
}
34+
return memoryConsent;
35+
}
36+
37+
export function setStoredConsent(choice: ConsentChoice): void {
38+
if (typeof window === 'undefined') return;
39+
memoryConsent = choice;
40+
try {
41+
window.localStorage.setItem(CONSENT_STORAGE_KEY, choice);
42+
} catch {
43+
// Storage blocked — the in-memory value keeps the choice for this session.
44+
}
45+
window.dispatchEvent(new CustomEvent<ConsentChoice>(CONSENT_CHANGE_EVENT, { detail: choice }));
46+
}
47+
48+
// Only analytics storage is managed (no ads).
49+
export function updateGtagConsent(choice: ConsentChoice): void {
50+
if (typeof window === 'undefined' || typeof window.gtag !== 'function') return;
51+
window.gtag('consent', 'update', { analytics_storage: choice });
52+
}
53+
54+
// Deletes GA first-party cookies (`_ga*`) so a withdrawn consent also removes
55+
// identifiers already on the device. No-op when none exist.
56+
export function clearAnalyticsCookies(): void {
57+
if (typeof document === 'undefined') return;
58+
59+
const names = document.cookie
60+
.split('; ')
61+
.map((cookie) => cookie.split('=')[0])
62+
.filter((name) => name.startsWith('_ga'));
63+
if (names.length === 0) return;
64+
65+
// GA scopes cookies to the host or the registrable domain (sometimes with a
66+
// leading dot), so expire every plausible domain/path combination.
67+
const { hostname } = window.location;
68+
const domains = ['', hostname, `.${hostname}`];
69+
const parts = hostname.split('.');
70+
if (parts.length > 2) {
71+
const registrable = parts.slice(-2).join('.');
72+
domains.push(registrable, `.${registrable}`);
73+
}
74+
75+
for (const name of names) {
76+
for (const domain of domains) {
77+
const scope = domain ? `; domain=${domain}` : '';
78+
document.cookie = `${name}=; path=/; max-age=0${scope}`;
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)