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
10 changes: 10 additions & 0 deletions src/app/(marketing)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -62,6 +63,15 @@ export default async function PrivacyPage() {
<p className="mt-2 text-sm">{p("analyticsText")}</p>
</div>

{/* Manage consent */}
<div>
<h2 className="border-l-2 border-brand-400 pl-3 text-lg font-semibold text-gray-900">
{p("manageTitle")}
</h2>
<p className="mt-2 text-sm">{p("manageText")}</p>
<ConsentControls />
</div>

{/* Contact Form */}
<div>
<h2 className="border-l-2 border-brand-400 pl-3 text-lg font-semibold text-gray-900">
Expand Down
58 changes: 58 additions & 0 deletions src/components/ConsentControls.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-4 rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-700">
{t("consentStatus")}{" "}
<span className="font-semibold text-gray-900">{statusLabel}</span>
</p>
<div className="mt-3 flex flex-wrap gap-3">
{state === "granted" ? (
<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("revokeConsent")}
</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("grantConsent")}
</button>
)}
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions src/components/CookieConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setConsent,
subscribeConsent,
markGtagReady,
setGaId,
type ConsentState,
} from "@/lib/analytics";

Expand All @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src/lib/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>)[key]).toBe(true);

a.setConsent("granted");
expect((window as unknown as Record<string, boolean>)[key]).toBe(false);

// Withdrawing consent re-disables an already-loaded GA.
a.setConsent("denied");
expect((window as unknown as Record<string, boolean>)[key]).toBe(true);
});
});
19 changes: 19 additions & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,30 @@ interface QueuedEvent {

let consent: ConsentState = "unknown";
let initialized = false;
let gaId: string | undefined;
const queue: QueuedEvent[] = [];
const listeners = new Set<() => void>();

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<string, boolean>)[`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 {
Expand All @@ -40,6 +57,7 @@ function ensureInit() {
if (!initialized && typeof window !== "undefined") {
consent = readStored();
initialized = true;
applyGaDisable();
}
}

Expand All @@ -66,6 +84,7 @@ export function setConsent(next: "granted" | "denied") {
/* ignore storage errors (private mode etc.) */
}
}
applyGaDisable();
if (next === "granted") {
flush();
} else {
Expand Down
12 changes: 10 additions & 2 deletions src/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
Expand Down
12 changes: 10 additions & 2 deletions src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
Expand Down
12 changes: 10 additions & 2 deletions src/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
Expand Down
12 changes: 10 additions & 2 deletions src/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
Expand Down
Loading