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
24 changes: 19 additions & 5 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,26 @@ export default function PrivacyPage() {
</ul>
</Section>

<Section title="What we don't collect">
<Section title="Analytics &amp; cookies">
<p className="mb-3">
We use Google Analytics to understand which pages are useful — nothing
more. It loads <strong>only if you accept it</strong> 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
&ldquo;Cookie choices&rdquo; at the bottom of any page.
</p>
<ul className="list-disc space-y-1 pl-5">
<li>No third-party analytics or tracking scripts</li>
<li>No advertising cookies</li>
<li>No selling or sharing of your data with third parties</li>
<li>No behavioral tracking or profiling</li>
<li>
<strong>Essential cookies</strong> (login/session) are always used —
they&apos;re required for the site to work and set no marketing data.
</li>
<li>
<strong>Analytics cookies</strong> (Google Analytics) are set only after
you opt in, and only measure aggregate page usage.
</li>
<li>No advertising or retargeting cookies</li>
<li>No selling of your data</li>
<li>No behavioral profiling for advertising</li>
</ul>
</Section>

Expand Down
19 changes: 5 additions & 14 deletions components/site/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,20 +28,10 @@ export async function Analytics() {
if (!provider || !siteId) return null;

if (provider === "ga4") {
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${siteId}`}
strategy="afterInteractive"
/>
<Script id="ga4-init" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${siteId}');`}
</Script>
</>
);
// 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 <ConsentAnalytics measurementId={siteId} />;
}

if (provider === "plausible") {
Expand Down
118 changes: 118 additions & 0 deletions components/site/consent-analytics.tsx
Original file line number Diff line number Diff line change
@@ -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<State>("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 && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${safeId}`}
strategy="afterInteractive"
/>
<Script id="ga4-init" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${safeId}');`}
</Script>
</>
)}

{state === "none" && (
<ConsentBanner onAccept={() => decide("granted")} onDecline={() => decide("denied")} />
)}
</>
);
}

function ConsentBanner({ onAccept, onDecline }: { onAccept: () => void; onDecline: () => void }) {
return (
<div
role="region"
aria-label="Cookie consent"
className="fixed inset-x-0 bottom-0 z-50 border-t border-conviction/30 bg-midnight/95 backdrop-blur"
>
<div className="mx-auto flex max-w-[1200px] flex-col gap-4 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-relaxed text-bone/70">
We&apos;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&apos;t
load anything until you choose. See our{" "}
<a href="/privacy" className="text-conviction underline underline-offset-2">
privacy policy
</a>
.
</p>
{/* Equal-weight choices — no dark pattern. */}
<div className="flex shrink-0 gap-3">
<button
type="button"
onClick={onDecline}
className="border border-bone/20 px-5 py-2 text-sm font-medium text-bone transition-colors hover:border-conviction hover:text-conviction"
style={{ borderRadius: "2px" }}
>
Decline
</button>
<button
type="button"
onClick={onAccept}
className="bg-conviction px-5 py-2 text-sm font-medium text-midnight transition-colors hover:bg-conviction/90"
style={{ borderRadius: "2px" }}
>
Accept
</button>
</div>
</div>
</div>
);
}
38 changes: 38 additions & 0 deletions components/site/cookie-settings-link.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={() => window.dispatchEvent(new Event("akritos:open-consent"))}
className="hover:text-bone/40"
>
Cookie choices
</button>
);
}
2 changes: 2 additions & 0 deletions components/site/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -88,6 +89,7 @@ export function SiteFooter({ companyName = "Akritos" }: { companyName?: string }
<div className="flex gap-4 text-xs text-bone/20">
<Link href="/privacy" className="hover:text-bone/40">Privacy</Link>
<Link href="/terms" className="hover:text-bone/40">Terms</Link>
<CookieSettingsLink />
</div>
</div>
</div>
Expand Down
Loading