diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 2195711..cb603e0 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -52,12 +52,26 @@ export default function PrivacyPage() { -
+
+

+ We use Google Analytics to understand which pages are useful — nothing + more. It loads only if you accept it in the cookie banner. + Decline and no analytics scripts run and no analytics cookies are set; the + site works exactly the same. You can change your choice anytime via + “Cookie choices” at the bottom of any page. +

diff --git a/components/site/analytics.tsx b/components/site/analytics.tsx index 560e1ad..57220aa 100644 --- a/components/site/analytics.tsx +++ b/components/site/analytics.tsx @@ -5,6 +5,7 @@ import Script from "next/script"; import { db } from "@/server/db"; +import { ConsentAnalytics } from "@/components/site/consent-analytics"; async function getAnalyticsConfig() { try { @@ -27,20 +28,10 @@ export async function Analytics() { if (!provider || !siteId) return null; if (provider === "ga4") { - return ( - <> - - - ); + // GA4 sets cookies, so it's consent-gated: the script loads only after the + // visitor opts in via the banner. Plausible/Umami below are cookieless and + // need no consent, so they load directly. + return ; } if (provider === "plausible") { diff --git a/components/site/consent-analytics.tsx b/components/site/consent-analytics.tsx new file mode 100644 index 0000000..d8ea0c5 --- /dev/null +++ b/components/site/consent-analytics.tsx @@ -0,0 +1,118 @@ +// components/site/consent-analytics.tsx +"use client"; + +// GA4 with real consent gating: the Google script is NOT loaded until the +// visitor opts in. Declining loads nothing and sets no cookies. The choice is +// stored in localStorage (not a cookie — so storing the choice itself needs no +// consent) and can be changed later via the footer "Cookie choices" link, which +// dispatches `akritos:open-consent`. After any decision we emit +// `akritos:consent-changed` so that link can reveal itself without a reload. + +import Script from "next/script"; +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "akritos_analytics_consent"; +type Choice = "granted" | "denied"; +// "pending" = SSR / pre-hydration: render nothing so the banner never flashes +// for someone who already chose, and GA4 never loads before we've checked. +type State = "pending" | "none" | Choice; + +export function ConsentAnalytics({ measurementId }: { measurementId: string }) { + const [state, setState] = useState("pending"); + + useEffect(() => { + const read = () => { + try { + const v = localStorage.getItem(STORAGE_KEY); + setState(v === "granted" || v === "denied" ? v : "none"); + } catch { + setState("none"); + } + }; + read(); + const reopen = () => setState("none"); + window.addEventListener("akritos:open-consent", reopen); + return () => window.removeEventListener("akritos:open-consent", reopen); + }, []); + + function decide(next: Choice) { + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + // storage blocked — still honor the choice for this session + } + setState(next); + if (typeof window !== "undefined") { + window.dispatchEvent(new Event("akritos:consent-changed")); + } + } + + // GA4 measurement IDs are alphanumeric + hyphens (e.g. G-XXXXXXXXXX). Guard + // before interpolating into a script tag — defense-in-depth even though the + // value is admin-set in Settings. + const safeId = /^[A-Za-z0-9-]+$/.test(measurementId) ? measurementId : ""; + + return ( + <> + {state === "granted" && safeId && ( + <> + + + )} + + {state === "none" && ( + decide("granted")} onDecline={() => decide("denied")} /> + )} + + ); +} + +function ConsentBanner({ onAccept, onDecline }: { onAccept: () => void; onDecline: () => void }) { + return ( +
+
+

+ We'd like to use Google Analytics to see which pages are useful. It sets + cookies. You can decline — the site works exactly the same, and we won't + load anything until you choose. See our{" "} + + privacy policy + + . +

+ {/* Equal-weight choices — no dark pattern. */} +
+ + +
+
+
+ ); +} diff --git a/components/site/cookie-settings-link.tsx b/components/site/cookie-settings-link.tsx new file mode 100644 index 0000000..cd8d824 --- /dev/null +++ b/components/site/cookie-settings-link.tsx @@ -0,0 +1,38 @@ +// components/site/cookie-settings-link.tsx +"use client"; + +// Footer "Cookie choices" reopener. Self-hiding: only appears once a consent +// choice exists (i.e. GA4 consent is actually in play), so it's never a dead +// link on deployments using cookieless analytics or none at all. Clicking it +// re-opens the consent banner via the shared event. + +import { useEffect, useState } from "react"; + +export function CookieSettingsLink() { + const [show, setShow] = useState(false); + + useEffect(() => { + const check = () => { + try { + setShow(localStorage.getItem("akritos_analytics_consent") !== null); + } catch { + setShow(false); + } + }; + check(); + window.addEventListener("akritos:consent-changed", check); + return () => window.removeEventListener("akritos:consent-changed", check); + }, []); + + if (!show) return null; + + return ( + + ); +} diff --git a/components/site/site-footer.tsx b/components/site/site-footer.tsx index cb27c3b..bf15ba5 100644 --- a/components/site/site-footer.tsx +++ b/components/site/site-footer.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { LogoMark } from "@/components/brand/logo-mark"; import { Phone, Mail } from "lucide-react"; +import { CookieSettingsLink } from "@/components/site/cookie-settings-link"; export function SiteFooter({ companyName = "Akritos" }: { companyName?: string }) { return ( @@ -88,6 +89,7 @@ export function SiteFooter({ companyName = "Akritos" }: { companyName?: string }
Privacy Terms +