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
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,8 +54,8 @@ export default async function RootLayout({
<body className={`${geistSans.variable} antialiased`}>
<NextIntlClientProvider messages={messages}>
{children}
<CookieConsent gaId={gaId} />
</NextIntlClientProvider>
{gaId && <GoogleAnalytics gaId={gaId} />}
</body>
</html>
);
Expand Down
90 changes: 90 additions & 0 deletions src/components/CookieConsent.tsx
Original file line number Diff line number Diff line change
@@ -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 && <GoogleAnalytics gaId={gaId} />}

{state === "unknown" && (
<div className="fixed inset-x-0 bottom-0 z-[100] px-4 pb-4">
<div
role="dialog"
aria-label={t("title")}
className="mx-auto flex max-w-3xl flex-col gap-3 rounded-xl border border-gray-200 bg-white/95 p-4 shadow-lg backdrop-blur-md sm:flex-row sm:items-center sm:justify-between sm:gap-6"
>
<p className="text-sm text-gray-600">
{t("message")}{" "}
<Link
href="/privacy"
className="font-medium text-brand-600 underline-offset-2 hover:underline"
>
{t("learnMore")}
</Link>
</p>
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={() => setConsent("denied")}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
{t("decline")}
</button>
<button
type="button"
onClick={() => setConsent("granted")}
className="rounded-lg bg-cta-gradient px-4 py-2 text-sm font-medium text-white transition-transform hover:scale-105"
>
{t("accept")}
</button>
</div>
</div>
</div>
)}
</>
);
}
95 changes: 95 additions & 0 deletions src/lib/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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<Analytics> {
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);
});
});
115 changes: 112 additions & 3 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>;
}

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<string, string | number>,
) {
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();
}
7 changes: 7 additions & 0 deletions src/messages/de.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/messages/en.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/messages/es.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/messages/fr.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading