diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 22a0fa1..c888701 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; -import { GoogleAnalytics } from "@next/third-parties/google"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; +import { CookieConsent } from "@/components/CookieConsent"; import "./globals.css"; const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; @@ -54,8 +54,8 @@ export default async function RootLayout({ {children} + - {gaId && } ); diff --git a/src/components/CookieConsent.tsx b/src/components/CookieConsent.tsx new file mode 100644 index 0000000..5a2bf5f --- /dev/null +++ b/src/components/CookieConsent.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useSyncExternalStore } from "react"; +import Link from "next/link"; +import { GoogleAnalytics } from "@next/third-parties/google"; +import { useTranslations } from "next-intl"; +import { + getConsent, + setConsent, + subscribeConsent, + markGtagReady, + type ConsentState, +} from "@/lib/analytics"; + +interface Props { + gaId?: string; +} + +// On the server (and the first hydration render) the stored choice is unknown, +// so render neither the banner nor analytics until the client reads storage. +const serverConsent = (): ConsentState => "denied"; + +/** + * Cookie consent banner + consent-gated analytics loader. Google Analytics is + * only mounted after the user accepts, so no tracking cookies or requests occur + * beforehand. Events fired before the decision are queued and flushed on accept. + */ +export function CookieConsent({ gaId }: Props) { + const t = useTranslations("consent"); + const state = useSyncExternalStore(subscribeConsent, getConsent, serverConsent); + const granted = state === "granted"; + + // Once analytics is granted, wait for gtag to exist, then drain the queue. + useEffect(() => { + if (!granted || !gaId) return; + if (typeof window !== "undefined" && typeof window.gtag === "function") { + markGtagReady(); + return; + } + const id = window.setInterval(() => { + if (typeof window.gtag === "function") { + markGtagReady(); + window.clearInterval(id); + } + }, 200); + return () => window.clearInterval(id); + }, [granted, gaId]); + + return ( + <> + {gaId && granted && } + + {state === "unknown" && ( +
+
+

+ {t("message")}{" "} + + {t("learnMore")} + +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/lib/__tests__/analytics.test.ts b/src/lib/__tests__/analytics.test.ts new file mode 100644 index 0000000..b6d8aab --- /dev/null +++ b/src/lib/__tests__/analytics.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +type Analytics = typeof import("@/lib/analytics"); + +// The analytics module keeps singleton state, so reload it fresh per test. +async function load(): Promise { + vi.resetModules(); + return import("@/lib/analytics"); +} + +function setGtag() { + const gtag = vi.fn(); + (window as unknown as { gtag: typeof gtag }).gtag = gtag; + return gtag; +} + +beforeEach(() => { + window.localStorage.clear(); + (window as unknown as { gtag?: unknown }).gtag = undefined; +}); + +describe("consent-aware analytics", () => { + it("does not send events before consent, then delivers them on accept", async () => { + const gtag = setGtag(); + const a = await load(); + + expect(a.getConsent()).toBe("unknown"); + a.trackEvent("language_switch", { locale: "de" }); + a.trackEvent("contact_form_submit"); + expect(gtag).not.toHaveBeenCalled(); // nothing sent yet + + a.setConsent("granted"); + // queued events are flushed in order + expect(gtag).toHaveBeenCalledTimes(2); + expect(gtag).toHaveBeenNthCalledWith(1, "event", "language_switch", { locale: "de" }); + expect(gtag).toHaveBeenNthCalledWith(2, "event", "contact_form_submit", undefined); + + // subsequent events fire live + a.trackEvent("disclaimer_accepted"); + expect(gtag).toHaveBeenCalledTimes(3); + expect(gtag).toHaveBeenLastCalledWith("event", "disclaimer_accepted", undefined); + }); + + it("never sends anything when consent is denied", async () => { + const gtag = setGtag(); + const a = await load(); + + a.trackEvent("language_switch", { locale: "en" }); + a.setConsent("denied"); + a.trackEvent("contact_form_submit"); + + expect(gtag).not.toHaveBeenCalled(); + expect(a.getConsent()).toBe("denied"); + }); + + it("holds events until gtag is ready, then flushes on markGtagReady", async () => { + const a = await load(); // no gtag yet + + a.setConsent("granted"); + a.trackEvent("language_switch", { locale: "fr" }); + // gtag not present -> nothing delivered, still queued + + const gtag = setGtag(); + a.markGtagReady(); + expect(gtag).toHaveBeenCalledTimes(1); + expect(gtag).toHaveBeenCalledWith("event", "language_switch", { locale: "fr" }); + }); + + it("restores a previously granted choice and fires live", async () => { + window.localStorage.setItem("pp_cookie_consent", "granted"); + const gtag = setGtag(); + const a = await load(); + + expect(a.getConsent()).toBe("granted"); + a.trackEvent("disclaimer_accepted"); + expect(gtag).toHaveBeenCalledWith("event", "disclaimer_accepted", undefined); + }); + + it("persists the choice to localStorage", async () => { + const a = await load(); + a.setConsent("granted"); + expect(window.localStorage.getItem("pp_cookie_consent")).toBe("granted"); + }); + + it("notifies and can unsubscribe subscribers", async () => { + const a = await load(); + const cb = vi.fn(); + const unsub = a.subscribeConsent(cb); + a.setConsent("granted"); + expect(cb).toHaveBeenCalledTimes(1); + unsub(); + a.setConsent("denied"); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 5936e3b..5c61871 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,8 +1,117 @@ +/** + * Consent-aware analytics. + * + * No analytics event is sent until the user grants consent. Events fired before + * a decision are held in an in-memory queue (nothing leaves the browser) and + * flushed once consent is granted; after that they fire live. If consent is + * denied the queue is dropped and nothing is ever sent. + */ + +export type ConsentState = "unknown" | "granted" | "denied"; + +const STORAGE_KEY = "pp_cookie_consent"; + +interface QueuedEvent { + name: string; + params?: Record; +} + +let consent: ConsentState = "unknown"; +let initialized = false; +const queue: QueuedEvent[] = []; +const listeners = new Set<() => void>(); + +function notify() { + for (const l of listeners) l(); +} + +function readStored(): ConsentState { + if (typeof window === "undefined") return "unknown"; + try { + const v = window.localStorage.getItem(STORAGE_KEY); + return v === "granted" || v === "denied" ? v : "unknown"; + } catch { + return "unknown"; + } +} + +/** Read the persisted choice once on the client. Safe to call repeatedly. */ +function ensureInit() { + if (!initialized && typeof window !== "undefined") { + consent = readStored(); + initialized = true; + } +} + +export function getConsent(): ConsentState { + ensureInit(); + return consent; +} + +/** Subscribe to consent changes; returns an unsubscribe function. */ +export function subscribeConsent(cb: () => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +export function setConsent(next: "granted" | "denied") { + consent = next; + initialized = true; + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + /* ignore storage errors (private mode etc.) */ + } + } + if (next === "granted") { + flush(); + } else { + queue.length = 0; + } + notify(); +} + +function fireNow(e: QueuedEvent): boolean { + if (typeof window !== "undefined" && typeof window.gtag === "function") { + window.gtag("event", e.name, e.params); + return true; + } + return false; +} + +/** + * Deliver any queued events. Only runs once consent is granted AND the gtag + * function exists; otherwise events stay queued until both are true. + */ +export function flush() { + if (consent !== "granted") return; + if (typeof window === "undefined" || typeof window.gtag !== "function") return; + while (queue.length) { + fireNow(queue.shift()!); + } +} + +/** Called by the analytics loader once gtag is available, to drain the queue. */ +export function markGtagReady() { + flush(); +} + export function trackEvent( name: string, params?: Record, ) { - if (typeof window !== "undefined" && window.gtag) { - window.gtag("event", name, params); - } + ensureInit(); + if (consent === "denied") return; + if (consent === "granted" && fireNow({ name, params })) return; + // Unknown consent, or granted but gtag not ready yet: hold for later delivery. + queue.push({ name, params }); +} + +// Read the stored choice as soon as the module loads on the client, so a +// returning visitor's consent is known before any event or render. +if (typeof window !== "undefined") { + ensureInit(); } diff --git a/src/messages/de.json b/src/messages/de.json index a366d9d..e629078 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1,4 +1,11 @@ { + "consent": { + "title": "Cookie-Hinweis", + "message": "Wir verwenden Cookies für anonyme Statistik, um die Seite zu verbessern. Bis du zustimmst, wird nichts getrackt.", + "accept": "Akzeptieren", + "decline": "Ablehnen", + "learnMore": "Mehr erfahren" + }, "nav": { "brand": "PackedPlaces.com", "howItWorks": "So funktioniert's", diff --git a/src/messages/en.json b/src/messages/en.json index 831aa12..06024fa 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1,4 +1,11 @@ { + "consent": { + "title": "Cookie notice", + "message": "We use cookies for anonymous analytics to improve the site. Nothing is tracked until you accept.", + "accept": "Accept", + "decline": "Decline", + "learnMore": "Learn more" + }, "nav": { "brand": "PackedPlaces.com", "howItWorks": "How It Works", diff --git a/src/messages/es.json b/src/messages/es.json index 597feb4..d60fb95 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1,4 +1,11 @@ { + "consent": { + "title": "Aviso de cookies", + "message": "Usamos cookies para analítica anónima y mejorar el sitio. No se rastrea nada hasta que aceptes.", + "accept": "Aceptar", + "decline": "Rechazar", + "learnMore": "Más información" + }, "nav": { "brand": "PackedPlaces.com", "howItWorks": "Cómo funciona", diff --git a/src/messages/fr.json b/src/messages/fr.json index bd4498c..0236af6 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1,4 +1,11 @@ { + "consent": { + "title": "Avis sur les cookies", + "message": "Nous utilisons des cookies pour des statistiques anonymes afin d'améliorer le site. Rien n'est suivi tant que vous n'avez pas accepté.", + "accept": "Accepter", + "decline": "Refuser", + "learnMore": "En savoir plus" + }, "nav": { "brand": "PackedPlaces.com", "howItWorks": "Comment ça marche",