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

@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() {
const { choice, openSettings } = useAnalyticsConsent();
const { enabled, toggleAnalytics } = useAnalyticsConsent();
return (
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
@@ -18,10 +18,11 @@ export function Footer() {
Privacy & legal
</Link>
<button
onClick={openSettings}
onClick={toggleAnalytics}
aria-pressed={!enabled}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
>
Analytics settings ({choice === "granted" ? "on" : "off"})
Analytics {enabled ? "on" : "off"}
</button>
</nav>
</div>

View File

@@ -7,6 +7,7 @@ export interface PlimiRuntimeConfig {
VITE_PRIVACY_EFFECTIVE_DATE?: string;
VITE_ANALYTICS_PROVIDER_NAME?: string;
VITE_ANALYTICS_SCRIPT_URL?: string;
VITE_ANALYTICS_HOST_URL?: string;
VITE_ANALYTICS_WEBSITE_ID?: string;
VITE_ANALYTICS_HOST?: string;
VITE_ANALYTICS_HOST_COUNTRY?: string;

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>
);
}

View File

@@ -1,20 +1,16 @@
import { createContext, useContext } from "react";
export type ConsentChoice = "pending" | "granted" | "denied";
export interface AnalyticsConsentValue {
choice: ConsentChoice;
isSettingsOpen: boolean;
accept: () => void;
reject: () => void;
openSettings: () => void;
closeSettings: () => void;
export interface AnalyticsPreferenceValue {
enabled: boolean;
disableAnalytics: () => void;
enableAnalytics: () => void;
toggleAnalytics: () => void;
}
export const AnalyticsConsentContext =
createContext<AnalyticsConsentValue | null>(null);
createContext<AnalyticsPreferenceValue | null>(null);
export function useAnalyticsConsent(): AnalyticsConsentValue {
export function useAnalyticsConsent(): AnalyticsPreferenceValue {
const context = useContext(AnalyticsConsentContext);
if (!context) {
throw new Error(

View File

@@ -27,6 +27,10 @@ export const privacyConfig = {
"VITE_ANALYTICS_SCRIPT_URL",
"https://u.achraf.app/script.js"
),
analyticsHostUrl: runtimeConfigValue(
"VITE_ANALYTICS_HOST_URL",
"https://u.achraf.app"
),
analyticsWebsiteId: runtimeConfigValue(
"VITE_ANALYTICS_WEBSITE_ID",
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"

View File

@@ -94,7 +94,8 @@ export function HowItWorksPage() {
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
Plimi is a collection of small local utilities. The interface loads the tool, runs the
work in the browser, then hands the result back to you without uploading your tool
input. Optional audience analytics are separate and require your consent.
input. Optional audience measurement stays separate from tool processing and can be
disabled from the privacy controls.
</p>
</div>

View File

@@ -21,7 +21,7 @@ function Section({
}
export function PrivacyPage() {
const { choice, openSettings } = useAnalyticsConsent();
const { enabled, enableAnalytics, disableAnalytics } = useAnalyticsConsent();
return (
<div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide">
@@ -30,7 +30,7 @@ export function PrivacyPage() {
privacy notice & legal information
</span>
<h1 className="m-0 font-sans text-4xl font-bold leading-[1.03] tracking-tight text-[var(--p-text)] md:text-[52px]">
Your tools are local. Analytics are optional.
Your tools stay local. Analytics stay narrow.
</h1>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains
@@ -72,55 +72,73 @@ export function PrivacyPage() {
</p>
</Section>
<Section title="Optional analytics">
<Section title="Audience measurement">
<p className="m-0">
Analytics are disabled until you consent. If accepted, the site loads{" "}
{privacyConfig.analyticsProvider} from {privacyConfig.analyticsHost}.
It may process the visited route, referrer, timestamp, browser,
operating system, device type, screen size, language, and an
approximate location derived from network information.
Plimi uses a self-hosted {privacyConfig.analyticsProvider} audience
measurement script from {privacyConfig.analyticsHost}. It measures
page visits using cookieless analytics and may process the visited
route, timestamp, browser, operating system, device type, screen
size, language, referrer, and an approximate country derived from
network information.
</p>
<p className="m-0">
Purpose: aggregate audience measurement and improvement of Plimi.
Legal basis: your consent under GDPR Article 6(1)(a). Analytics data
is configured for a retention period of{" "}
Plimi does not use analytics for advertising, profiling, sharing with
data brokers, or cross-site tracking. Analytics data is configured
for a retention period of{" "}
{privacyConfig.analyticsRetentionMonths} months and is hosted in{" "}
{privacyConfig.analyticsHostCountry}.
</p>
<p className="m-0">
Plimi does not send tool inputs, uploaded file contents, outputs,
names, email addresses, or account identifiers to analytics. The
tracker is configured to exclude URL search parameters and hashes and
to honor browser Do Not Track.
names, email addresses, account identifiers, custom analytics events,
or `umami.identify()` data. The tracker excludes URL search
parameters and hash fragments and honors browser Do Not Track.
</p>
<div className="flex flex-wrap items-center gap-3 pt-2">
<button
onClick={openSettings}
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
>
Change analytics choice
</button>
{enabled ? (
<button
onClick={disableAnalytics}
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)]"
>
Disable analytics
</button>
) : (
<button
onClick={enableAnalytics}
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
>
Enable analytics
</button>
)}
<span className="font-mono text-[11px] uppercase text-[var(--p-muted)]">
Current choice: {choice === "granted" ? "accepted" : choice === "denied" ? "rejected" : "not chosen"}
Analytics are currently {enabled ? "enabled" : "disabled"} on this
browser
</span>
</div>
<p className="m-0">
Opt out locally at any time. Plimi sets{" "}
<code className="text-[var(--p-text)]">localStorage["umami.disabled"] = "1"</code>{" "}
when you disable analytics and removes that key when you re-enable it.
</p>
</Section>
<Section title="Local storage and essential preferences">
<p className="m-0">
Plimi stores your theme preference and analytics choice in browser
local storage. These values are necessary to remember the settings
you selected. Plimi does not use advertising cookies.
Plimi stores your theme preference and analytics opt-out preference in
browser local storage. These values are used only to remember your
settings. Plimi does not use analytics cookies, advertising cookies,
or marketing pixels.
</p>
</Section>
<Section title="Recipients and transfers">
<p className="m-0">
When analytics are accepted, analytics metadata is received by the
When analytics are enabled, analytics metadata is received by the
operator of {privacyConfig.analyticsHost} and any infrastructure
providers used to host it. The controller must maintain appropriate
processor agreements and transfer safeguards where a provider
processes data outside the EEA.
providers used to host it. Plimi does not load other non-essential
trackers, third-party embeds, advertising pixels, or cross-site
analytics services.
</p>
</Section>
@@ -128,8 +146,8 @@ export function PrivacyPage() {
<p className="m-0">
Depending on applicable law, you may request access, correction,
deletion, restriction, or portability of your personal data. You may
withdraw analytics consent at any time through Analytics settings;
withdrawal does not affect prior lawful processing.
opt out of analytics at any time through the controls on this page or
in the footer.
</p>
<p className="m-0">
Contact {privacyConfig.privacyEmail} to exercise a right. You may also