Switch analytics privacy controls to opt-out
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user