From 4bfcd546ae1c3ccd77584cda12cca641963b65f5 Mon Sep 17 00:00:00 2001 From: "Alfred (Manuels Agent)" Date: Sun, 5 Jul 2026 09:34:10 +0200 Subject: [PATCH] feat(privacy): grant/withdraw consent from the privacy policy Add a "Manage your consent" section to the privacy page with a button to grant or withdraw analytics consent at any time, wired to the same consent store as the cookie banner: - ConsentControls: client component showing the current choice and a grant/withdraw button. Changing it takes effect immediately and persists. - analytics: register the GA id (setGaId) and toggle Google Analytics' official ga-disable- flag on consent changes, so an already-loaded GA stops sending the moment consent is withdrawn and resumes on re-grant. - CookieConsent registers the GA id. - Privacy policy texts updated for all locales (en/de/es/fr): the analytics section now describes consent gating and points to the controls below; added the manage section strings; bumped the last-updated date. Supersedes #13 (the standalone GA-text update is folded in here). Co-Authored-By: Claude Opus 4.8 --- src/app/(marketing)/privacy/page.tsx | 10 +++++ src/components/ConsentControls.tsx | 58 ++++++++++++++++++++++++++++ src/components/CookieConsent.tsx | 6 +++ src/lib/__tests__/analytics.test.ts | 16 ++++++++ src/lib/analytics.ts | 19 +++++++++ src/messages/de.json | 12 +++++- src/messages/en.json | 12 +++++- src/messages/es.json | 12 +++++- src/messages/fr.json | 12 +++++- 9 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 src/components/ConsentControls.tsx diff --git a/src/app/(marketing)/privacy/page.tsx b/src/app/(marketing)/privacy/page.tsx index c6e7b78..5dc7cfd 100644 --- a/src/app/(marketing)/privacy/page.tsx +++ b/src/app/(marketing)/privacy/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { SiteHeader } from "@/components/SiteHeader"; import { SiteFooter } from "@/components/SiteFooter"; +import { ConsentControls } from "@/components/ConsentControls"; export const metadata: Metadata = { title: "Privacy Policy — PackedPlaces.com", @@ -62,6 +63,15 @@ export default async function PrivacyPage() {

{p("analyticsText")}

+ {/* Manage consent */} +
+

+ {p("manageTitle")} +

+

{p("manageText")}

+ +
+ {/* Contact Form */}

diff --git a/src/components/ConsentControls.tsx b/src/components/ConsentControls.tsx new file mode 100644 index 0000000..77292a5 --- /dev/null +++ b/src/components/ConsentControls.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { useTranslations } from "next-intl"; +import { + getConsent, + setConsent, + subscribeConsent, + type ConsentState, +} from "@/lib/analytics"; + +// The stored choice is unknown on the server; reflect that until the client reads it. +const serverConsent = (): ConsentState => "unknown"; + +/** + * Lets the visitor grant or withdraw analytics consent from the privacy page. + * Reads the same consent store as the banner, so a change here also toggles + * Google Analytics live and is remembered. + */ +export function ConsentControls() { + const t = useTranslations("privacy"); + const state = useSyncExternalStore(subscribeConsent, getConsent, serverConsent); + + const statusLabel = + state === "granted" + ? t("statusGranted") + : state === "denied" + ? t("statusDenied") + : t("statusUnknown"); + + return ( +
+

+ {t("consentStatus")}{" "} + {statusLabel} +

+
+ {state === "granted" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/CookieConsent.tsx b/src/components/CookieConsent.tsx index 5a2bf5f..b2b97d9 100644 --- a/src/components/CookieConsent.tsx +++ b/src/components/CookieConsent.tsx @@ -9,6 +9,7 @@ import { setConsent, subscribeConsent, markGtagReady, + setGaId, type ConsentState, } from "@/lib/analytics"; @@ -30,6 +31,11 @@ export function CookieConsent({ gaId }: Props) { const state = useSyncExternalStore(subscribeConsent, getConsent, serverConsent); const granted = state === "granted"; + // Register the GA id so withdrawing consent later can disable an already-loaded GA. + useEffect(() => { + setGaId(gaId); + }, [gaId]); + // Once analytics is granted, wait for gtag to exist, then drain the queue. useEffect(() => { if (!granted || !gaId) return; diff --git a/src/lib/__tests__/analytics.test.ts b/src/lib/__tests__/analytics.test.ts index b6d8aab..f7dcb4d 100644 --- a/src/lib/__tests__/analytics.test.ts +++ b/src/lib/__tests__/analytics.test.ts @@ -92,4 +92,20 @@ describe("consent-aware analytics", () => { a.setConsent("denied"); expect(cb).toHaveBeenCalledTimes(1); }); + + it("toggles Google Analytics' opt-out flag when consent changes", async () => { + const key = "ga-disable-G-TESTID"; + const a = await load(); + a.setGaId("G-TESTID"); + + // Before any grant, GA is disabled. + expect((window as unknown as Record)[key]).toBe(true); + + a.setConsent("granted"); + expect((window as unknown as Record)[key]).toBe(false); + + // Withdrawing consent re-disables an already-loaded GA. + a.setConsent("denied"); + expect((window as unknown as Record)[key]).toBe(true); + }); }); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 5c61871..fd7e464 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -18,6 +18,7 @@ interface QueuedEvent { let consent: ConsentState = "unknown"; let initialized = false; +let gaId: string | undefined; const queue: QueuedEvent[] = []; const listeners = new Set<() => void>(); @@ -25,6 +26,22 @@ function notify() { for (const l of listeners) l(); } +/** + * Toggle Google Analytics' official opt-out flag so that even an already-loaded + * GA runtime stops sending once consent is withdrawn (and resumes on re-grant). + */ +function applyGaDisable() { + if (typeof window === "undefined" || !gaId) return; + (window as unknown as Record)[`ga-disable-${gaId}`] = + consent !== "granted"; +} + +/** Register the GA measurement id so consent changes can enable/disable it. */ +export function setGaId(id?: string) { + gaId = id; + applyGaDisable(); +} + function readStored(): ConsentState { if (typeof window === "undefined") return "unknown"; try { @@ -40,6 +57,7 @@ function ensureInit() { if (!initialized && typeof window !== "undefined") { consent = readStored(); initialized = true; + applyGaDisable(); } } @@ -66,6 +84,7 @@ export function setConsent(next: "granted" | "denied") { /* ignore storage errors (private mode etc.) */ } } + applyGaDisable(); if (next === "granted") { flush(); } else { diff --git a/src/messages/de.json b/src/messages/de.json index e629078..8e97882 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -158,7 +158,7 @@ }, "privacy": { "title": "Datenschutzerklärung", - "lastUpdated": "Zuletzt aktualisiert: März 2026", + "lastUpdated": "Zuletzt aktualisiert: Juli 2026", "controllerTitle": "Verantwortlicher", "controllerText": "PackedPlaces.com wird von 10ideen.at betrieben. Für datenschutzbezogene Anfragen nutzen Sie bitte unser Kontaktformular.", "collectTitle": "Welche Daten wir erheben", @@ -167,7 +167,15 @@ "cookiesText": "Wir verwenden ein einziges funktionales Cookie, um Ihre Spracheinstellung zu speichern:", "cookieLocale": "Speichert Ihre bevorzugte Sprache (en, de, es, fr). Läuft nach 1 Jahr ab.", "analyticsTitle": "Google Analytics", - "analyticsText": "Wir verwenden Google Analytics 4, um zu verstehen, wie Besucher unsere Website nutzen. Google Analytics erfasst anonymisierte Nutzungsdaten wie besuchte Seiten, Verweildauer und allgemeine geografische Region. Es werden keine personenbezogenen Daten erhoben. Sie können die Erfassung durch eine Browser-Erweiterung oder die Aktivierung von Do Not Track deaktivieren.", + "analyticsText": "Wir verwenden Google Analytics 4, um zu verstehen, wie Besucher unsere Website nutzen. Es wird erst geladen, nachdem Sie im Consent-Banner der Analyse zugestimmt haben. Bis zur Zustimmung läuft keine Analyse und es werden keine Analyse-Cookies gesetzt; lehnen Sie ab, wird es nie geladen. Bei aktivierter Analyse erfasst Google Analytics anonymisierte Nutzungsdaten wie besuchte Seiten, Verweildauer und allgemeine geografische Region. Es werden keine personenbezogenen Daten erhoben. Sie können Ihre Einwilligung jederzeit über die Schaltflächen unten ändern oder widerrufen.", + "manageTitle": "Einwilligung verwalten", + "manageText": "Sie können Ihre Einwilligung in die Analyse jederzeit erteilen oder widerrufen. Ihre Wahl wirkt sofort und wird auf diesem Gerät gespeichert.", + "consentStatus": "Aktuelle Wahl:", + "statusGranted": "Analyse akzeptiert", + "statusDenied": "Analyse abgelehnt", + "statusUnknown": "Noch keine Wahl getroffen", + "revokeConsent": "Einwilligung widerrufen", + "grantConsent": "Analyse akzeptieren", "contactDataTitle": "Kontaktformular-Daten", "contactDataText": "Wenn Sie unser Kontaktformular absenden, erheben wir Ihren Namen, Ihre E-Mail-Adresse und Ihre Nachricht. Diese Daten werden per E-Mail über Resend gesendet und ausschließlich zur Beantwortung Ihrer Anfrage verwendet. Wir speichern Kontaktformular-Eingaben nicht in einer Datenbank und geben sie nicht an Dritte weiter.", "rightsTitle": "Ihre Rechte (DSGVO)", diff --git a/src/messages/en.json b/src/messages/en.json index 06024fa..249f395 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -158,7 +158,7 @@ }, "privacy": { "title": "Privacy Policy", - "lastUpdated": "Last updated: March 2026", + "lastUpdated": "Last updated: July 2026", "controllerTitle": "Data Controller", "controllerText": "PackedPlaces.com is operated by 10ideen.at. For any privacy-related inquiries, please use our contact form.", "collectTitle": "What Data We Collect", @@ -167,7 +167,15 @@ "cookiesText": "We use a single functional cookie to remember your language preference:", "cookieLocale": "Stores your preferred language (en, de, es, fr). Expires after 1 year.", "analyticsTitle": "Google Analytics", - "analyticsText": "We use Google Analytics 4 to understand how visitors use our site. Google Analytics collects anonymized usage data including pages visited, time on site, and general geographic region. No personally identifiable information is collected. You can opt out by using a browser extension or enabling Do Not Track.", + "analyticsText": "We use Google Analytics 4 to understand how visitors use our site. It is only loaded after you accept analytics cookies in our consent banner. Until you accept, no analytics run and no analytics cookies are set, and if you decline it is never loaded. When enabled, Google Analytics collects anonymized usage data such as pages visited, time on site, and general geographic region. No personally identifiable information is collected. You can change or withdraw your consent at any time using the controls below.", + "manageTitle": "Manage your consent", + "manageText": "You can grant or withdraw your consent for analytics at any time. Your choice takes effect immediately and is remembered on this device.", + "consentStatus": "Current choice:", + "statusGranted": "Analytics accepted", + "statusDenied": "Analytics declined", + "statusUnknown": "No choice made yet", + "revokeConsent": "Withdraw consent", + "grantConsent": "Accept analytics", "contactDataTitle": "Contact Form Data", "contactDataText": "When you submit our contact form, we collect your name, email address, and message. This data is sent via email using Resend and is used solely to respond to your inquiry. We do not store contact form submissions in a database or share them with third parties.", "rightsTitle": "Your Rights (GDPR)", diff --git a/src/messages/es.json b/src/messages/es.json index d60fb95..880628e 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -158,7 +158,7 @@ }, "privacy": { "title": "Política de Privacidad", - "lastUpdated": "Última actualización: marzo 2026", + "lastUpdated": "Última actualización: julio 2026", "controllerTitle": "Responsable del tratamiento", "controllerText": "PackedPlaces.com es operado por 10ideen.at. Para consultas relacionadas con la privacidad, utilice nuestro formulario de contacto.", "collectTitle": "Qué datos recopilamos", @@ -167,7 +167,15 @@ "cookiesText": "Utilizamos una única cookie funcional para recordar su preferencia de idioma:", "cookieLocale": "Almacena su idioma preferido (en, de, es, fr). Expira después de 1 año.", "analyticsTitle": "Google Analytics", - "analyticsText": "Utilizamos Google Analytics 4 para comprender cómo los visitantes usan nuestro sitio. Google Analytics recopila datos de uso anónimos como páginas visitadas, tiempo en el sitio y región geográfica general. No se recopila información de identificación personal. Puede desactivarlo usando una extensión del navegador o habilitando Do Not Track.", + "analyticsText": "Utilizamos Google Analytics 4 para comprender cómo los visitantes usan nuestro sitio. Solo se carga después de que acepte las cookies de analítica en nuestro banner de consentimiento. Hasta que acepte, no se ejecuta ninguna analítica ni se instalan cookies de analítica, y si rechaza no se carga nunca. Cuando está activado, Google Analytics recopila datos de uso anónimos como páginas visitadas, tiempo en el sitio y región geográfica general. No se recopila información de identificación personal. Puede cambiar o retirar su consentimiento en cualquier momento con los controles de abajo.", + "manageTitle": "Gestionar su consentimiento", + "manageText": "Puede otorgar o retirar su consentimiento para la analítica en cualquier momento. Su elección surte efecto de inmediato y se recuerda en este dispositivo.", + "consentStatus": "Elección actual:", + "statusGranted": "Analítica aceptada", + "statusDenied": "Analítica rechazada", + "statusUnknown": "Aún no ha elegido", + "revokeConsent": "Retirar consentimiento", + "grantConsent": "Aceptar analítica", "contactDataTitle": "Datos del formulario de contacto", "contactDataText": "Cuando envía nuestro formulario de contacto, recopilamos su nombre, dirección de correo electrónico y mensaje. Estos datos se envían por correo electrónico a través de Resend y se utilizan únicamente para responder a su consulta. No almacenamos los envíos del formulario de contacto en una base de datos ni los compartimos con terceros.", "rightsTitle": "Sus derechos (RGPD)", diff --git a/src/messages/fr.json b/src/messages/fr.json index 0236af6..9215d2d 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -158,7 +158,7 @@ }, "privacy": { "title": "Politique de confidentialité", - "lastUpdated": "Dernière mise à jour : mars 2026", + "lastUpdated": "Dernière mise à jour : juillet 2026", "controllerTitle": "Responsable du traitement", "controllerText": "PackedPlaces.com est exploité par 10ideen.at. Pour toute question relative à la confidentialité, veuillez utiliser notre formulaire de contact.", "collectTitle": "Données que nous collectons", @@ -167,7 +167,15 @@ "cookiesText": "Nous utilisons un seul cookie fonctionnel pour mémoriser votre préférence linguistique :", "cookieLocale": "Stocke votre langue préférée (en, de, es, fr). Expire après 1 an.", "analyticsTitle": "Google Analytics", - "analyticsText": "Nous utilisons Google Analytics 4 pour comprendre comment les visiteurs utilisent notre site. Google Analytics collecte des données d'utilisation anonymisées telles que les pages visitées, le temps passé sur le site et la région géographique générale. Aucune information personnellement identifiable n'est collectée. Vous pouvez vous désinscrire en utilisant une extension de navigateur ou en activant Do Not Track.", + "analyticsText": "Nous utilisons Google Analytics 4 pour comprendre comment les visiteurs utilisent notre site. Il n'est chargé qu'après votre acceptation des cookies d'analyse dans notre bannière de consentement. Tant que vous n'avez pas accepté, aucune analyse ne s'exécute et aucun cookie d'analyse n'est déposé; si vous refusez, il n'est jamais chargé. Lorsqu'il est activé, Google Analytics collecte des données d'utilisation anonymisées telles que les pages visitées, le temps passé sur le site et la région géographique générale. Aucune information personnellement identifiable n'est collectée. Vous pouvez modifier ou retirer votre consentement à tout moment à l'aide des commandes ci-dessous.", + "manageTitle": "Gérer votre consentement", + "manageText": "Vous pouvez accorder ou retirer votre consentement à l'analyse à tout moment. Votre choix prend effet immédiatement et est mémorisé sur cet appareil.", + "consentStatus": "Choix actuel :", + "statusGranted": "Analyse acceptée", + "statusDenied": "Analyse refusée", + "statusUnknown": "Aucun choix effectué", + "revokeConsent": "Retirer le consentement", + "grantConsent": "Accepter l'analyse", "contactDataTitle": "Données du formulaire de contact", "contactDataText": "Lorsque vous soumettez notre formulaire de contact, nous collectons votre nom, adresse e-mail et message. Ces données sont envoyées par e-mail via Resend et sont utilisées uniquement pour répondre à votre demande. Nous ne stockons pas les soumissions du formulaire de contact dans une base de données et ne les partageons pas avec des tiers.", "rightsTitle": "Vos droits (RGPD)",