Switch analytics privacy controls to opt-out

This commit is contained in:
achraf
2026-06-07 16:57:48 +02:00
parent 796fa7bb4b
commit 25b9f36686
10 changed files with 102 additions and 176 deletions

View File

@@ -1,27 +1,23 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import {
hasAnalyticsConfiguration,
privacyConfig,
} from "./privacy-config";
import {
AnalyticsConsentContext,
type AnalyticsConsentValue,
type ConsentChoice,
type AnalyticsPreferenceValue,
} from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const SCRIPT_ID = "plimi-umami-script";
const UMAMI_DISABLED_KEY = "umami.disabled";
function storedChoice(): ConsentChoice {
const value = window.localStorage.getItem(CONSENT_KEY);
return value === "granted" || value === "denied" ? value : "pending";
function analyticsEnabledInitially(): boolean {
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1";
}
function removeAnalyticsScript() {
@@ -36,6 +32,7 @@ function loadAnalyticsScript() {
script.defer = true;
script.src = privacyConfig.analyticsScriptUrl;
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
script.dataset.hostUrl = privacyConfig.analyticsHostUrl;
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
script.dataset.doNotTrack = "true";
script.dataset.excludeSearch = "true";
@@ -43,145 +40,50 @@ function loadAnalyticsScript() {
document.head.appendChild(script);
}
function ConsentPanel({
settings,
onAccept,
onReject,
onClose,
}: {
settings: boolean;
onAccept: () => void;
onReject: () => void;
onClose?: () => void;
}) {
return (
<div
className={`fixed inset-x-3 z-[80] mx-auto max-w-[680px] rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:inset-x-6 md:p-6 ${
settings ? "top-1/2 -translate-y-1/2" : "bottom-3 md:bottom-6"
}`}
style={{ boxShadow: "0 24px 70px -28px var(--p-shadow-soft)" }}
role={settings ? "dialog" : "region"}
aria-modal={settings ? true : undefined}
aria-label="Analytics preferences"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
privacy choice
</div>
<h2 className="mb-0 mt-2 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
Optional, cookieless analytics
</h2>
</div>
{settings && onClose && (
<button
onClick={onClose}
aria-label="Close analytics preferences"
className="rounded-lg bg-[var(--p-chip)] px-3 py-2 text-sm text-[var(--p-muted)]"
>
Close
</button>
)}
</div>
<p className="mb-0 mt-3 text-sm leading-relaxed text-[var(--p-muted)]">
With your permission, Plimi loads {privacyConfig.analyticsProvider} from{" "}
{privacyConfig.analyticsHost} to measure page visits and device/browser
categories. Tool inputs, files, and outputs are never included.
</p>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
<Link
to="/privacy"
onClick={onClose}
className="mr-auto px-1 py-2 text-center text-sm text-[var(--p-muted)] underline decoration-[var(--p-border)] underline-offset-4"
>
Read the privacy notice
</Link>
<button
onClick={onReject}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-text)]"
>
Reject analytics
</button>
<button
onClick={onAccept}
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
>
Accept analytics
</button>
</div>
</div>
);
}
export function AnalyticsConsentProvider({
children,
}: {
children: ReactNode;
}) {
const [choice, setChoice] = useState<ConsentChoice>(storedChoice);
const [isSettingsOpen, setSettingsOpen] = useState(false);
const [enabled, setEnabled] = useState(analyticsEnabledInitially);
useEffect(() => {
if (choice === "granted") {
if (enabled) {
loadAnalyticsScript();
} else {
removeAnalyticsScript();
}
}, [choice]);
}, [enabled]);
const saveChoice = useCallback(
(nextChoice: Exclude<ConsentChoice, "pending">) => {
const wasGranted = choice === "granted";
window.localStorage.setItem(CONSENT_KEY, nextChoice);
setChoice(nextChoice);
setSettingsOpen(false);
if (wasGranted && nextChoice === "denied") {
window.location.reload();
}
},
[choice]
);
const value = useMemo<AnalyticsConsentValue>(
const value = useMemo<AnalyticsPreferenceValue>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
enabled,
disableAnalytics: () => {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
setEnabled(false);
window.location.reload();
},
enableAnalytics: () => {
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
setEnabled(true);
window.location.reload();
},
toggleAnalytics: () => {
if (enabled) {
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
} else {
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
}
setEnabled(!enabled);
window.location.reload();
},
}),
[choice, isSettingsOpen, saveChoice]
[enabled]
);
return (
<AnalyticsConsentContext.Provider value={value}>
{children}
{choice === "pending" && (
<ConsentPanel
settings={false}
onAccept={value.accept}
onReject={value.reject}
/>
)}
{isSettingsOpen && (
<>
<button
aria-label="Close analytics preferences"
onClick={value.closeSettings}
className="fixed inset-0 z-[70] bg-[color-mix(in_oklab,var(--p-bg)_70%,transparent)] backdrop-blur-sm"
/>
<ConsentPanel
settings
onAccept={value.accept}
onReject={value.reject}
onClose={value.closeSettings}
/>
</>
)}
</AnalyticsConsentContext.Provider>
);
}