Add consent-first analytics privacy controls

This commit is contained in:
achraf
2026-06-07 16:11:21 +02:00
parent aa44f6c105
commit 0b2d38ee5e
15 changed files with 492 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import { ToolsPage } from "../pages/ToolsPage";
import { ToolDetailPage } from "../pages/ToolDetailPage";
import { HowItWorksPage } from "../pages/HowItWorksPage";
import { ContributePage } from "../pages/ContributePage";
import { PrivacyPage } from "../pages/PrivacyPage";
export const router = createBrowserRouter([
{
@@ -31,6 +32,10 @@ export const router = createBrowserRouter([
path: "contribute",
element: <ContributePage />,
},
{
path: "privacy",
element: <PrivacyPage />,
},
],
},
]);

View File

@@ -1,13 +1,18 @@
import { Outlet } from "react-router-dom";
import { Header } from "./Header";
import { Footer } from "./Footer";
import { AnalyticsConsentProvider } from "../../core/privacy/AnalyticsConsent";
export function AppShell() {
return (
<div className="relative w-full min-h-screen bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
<Header />
<main className="px-6 py-8 md:px-14 md:py-9">
<Outlet />
</main>
</div>
<AnalyticsConsentProvider>
<div className="relative flex min-h-screen w-full flex-col bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
<Header />
<main className="flex-1 px-6 py-8 md:px-14 md:py-9">
<Outlet />
</main>
<Footer />
</div>
</AnalyticsConsentProvider>
);
}

View File

@@ -0,0 +1,30 @@
import { Link } from "react-router-dom";
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
export function Footer() {
const { choice, openSettings } = useAnalyticsConsent();
return (
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
<div className="mx-auto flex max-w-[1200px] flex-col gap-4 text-sm text-[var(--p-muted)] sm:flex-row sm:items-center sm:justify-between">
<span>
Plimi processes tool inputs locally in your browser.
</span>
<nav className="flex flex-wrap items-center gap-x-5 gap-y-2">
<Link
to="/privacy"
className="text-[var(--p-muted)] no-underline hover:text-[var(--p-text)]"
>
Privacy & legal
</Link>
<button
onClick={openSettings}
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
>
Analytics settings ({choice === "granted" ? "on" : "off"})
</button>
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,187 @@
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,
} from "./analytics-consent-context";
const CONSENT_KEY = "plimi.analytics-consent.v1";
const SCRIPT_ID = "plimi-umami-script";
function storedChoice(): ConsentChoice {
const value = window.localStorage.getItem(CONSENT_KEY);
return value === "granted" || value === "denied" ? value : "pending";
}
function removeAnalyticsScript() {
document.getElementById(SCRIPT_ID)?.remove();
}
function loadAnalyticsScript() {
if (!hasAnalyticsConfiguration || document.getElementById(SCRIPT_ID)) return;
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.defer = true;
script.src = privacyConfig.analyticsScriptUrl;
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
script.dataset.doNotTrack = "true";
script.dataset.excludeSearch = "true";
script.dataset.excludeHash = "true";
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);
useEffect(() => {
if (choice === "granted") {
loadAnalyticsScript();
} else {
removeAnalyticsScript();
}
}, [choice]);
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>(
() => ({
choice,
isSettingsOpen,
accept: () => saveChoice("granted"),
reject: () => saveChoice("denied"),
openSettings: () => setSettingsOpen(true),
closeSettings: () => setSettingsOpen(false),
}),
[choice, isSettingsOpen, saveChoice]
);
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

@@ -0,0 +1,25 @@
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 const AnalyticsConsentContext =
createContext<AnalyticsConsentValue | null>(null);
export function useAnalyticsConsent(): AnalyticsConsentValue {
const context = useContext(AnalyticsConsentContext);
if (!context) {
throw new Error(
"useAnalyticsConsent must be used inside AnalyticsConsentProvider."
);
}
return context;
}

View File

@@ -0,0 +1,32 @@
function env(name: keyof ImportMetaEnv, fallback: string): string {
const value = import.meta.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
export const privacyConfig = {
siteName: "Plimi",
siteUrl: env("VITE_SITE_URL", window.location.origin),
controllerName: env("VITE_LEGAL_NAME", "Plimi website operator"),
privacyEmail: env("VITE_PRIVACY_EMAIL", "privacy@example.com"),
controllerCountry: env("VITE_LEGAL_COUNTRY", "Not configured"),
policyEffectiveDate: env("VITE_PRIVACY_EFFECTIVE_DATE", "2026-06-07"),
analyticsProvider: env("VITE_ANALYTICS_PROVIDER_NAME", "Umami"),
analyticsScriptUrl: env(
"VITE_ANALYTICS_SCRIPT_URL",
"https://u.achraf.app/script.js"
),
analyticsWebsiteId: env(
"VITE_ANALYTICS_WEBSITE_ID",
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
),
analyticsHost: env("VITE_ANALYTICS_HOST", "u.achraf.app"),
analyticsHostCountry: env(
"VITE_ANALYTICS_HOST_COUNTRY",
"Not configured"
),
analyticsRetentionMonths: env("VITE_ANALYTICS_RETENTION_MONTHS", "13"),
};
export const hasAnalyticsConfiguration = Boolean(
privacyConfig.analyticsScriptUrl && privacyConfig.analyticsWebsiteId
);

View File

@@ -93,7 +93,8 @@ export function HowItWorksPage() {
</h1>
<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 a server round trip.
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.
</p>
</div>

151
src/pages/PrivacyPage.tsx Normal file
View File

@@ -0,0 +1,151 @@
import { useAnalyticsConsent } from "../core/privacy/analytics-consent-context";
import { privacyConfig } from "../core/privacy/privacy-config";
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5 md:p-7">
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
{title}
</h2>
<div className="mt-4 space-y-3 text-sm leading-relaxed text-[var(--p-muted)]">
{children}
</div>
</section>
);
}
export function PrivacyPage() {
const { choice, openSettings } = useAnalyticsConsent();
return (
<div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide">
<header className="flex flex-col gap-4">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
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.
</h1>
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
Effective {privacyConfig.policyEffectiveDate}. This notice explains
who operates Plimi, what data the website processes, and the choices
available to you.
</p>
</header>
<Section title="Controller and contact">
<p className="m-0">
The data controller is{" "}
<strong className="text-[var(--p-text)]">
{privacyConfig.controllerName}
</strong>
, based in {privacyConfig.controllerCountry}.
</p>
<p className="m-0">
Privacy requests:{" "}
<a
href={`mailto:${privacyConfig.privacyEmail}`}
className="text-[var(--p-text)] underline underline-offset-4"
>
{privacyConfig.privacyEmail}
</a>
. Website: {privacyConfig.siteUrl}.
</p>
</Section>
<Section title="Local tool processing">
<p className="m-0">
Text, files, images, PDFs, generated passwords, and tool outputs are
processed by code running in your browser. Plimi does not upload this
tool content to an application server or include it in analytics.
</p>
<p className="m-0">
Files you download remain under your control. Browser features such
as the clipboard or local file picker are used only when you invoke
them.
</p>
</Section>
<Section title="Optional analytics">
<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.
</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{" "}
{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.
</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>
<span className="font-mono text-[11px] uppercase text-[var(--p-muted)]">
Current choice: {choice === "granted" ? "accepted" : choice === "denied" ? "rejected" : "not chosen"}
</span>
</div>
</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.
</p>
</Section>
<Section title="Recipients and transfers">
<p className="m-0">
When analytics are accepted, 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.
</p>
</Section>
<Section title="Your rights">
<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.
</p>
<p className="m-0">
Contact {privacyConfig.privacyEmail} to exercise a right. You may also
lodge a complaint with the data-protection authority in your country
of residence, work, or the place of an alleged infringement.
</p>
</Section>
<Section title="Security and changes">
<p className="m-0">
Plimi uses HTTPS and access controls appropriate to the services it
operates. No internet service can guarantee absolute security. This
notice may be updated when processing or providers change; the
effective date above identifies the current version.
</p>
</Section>
</div>
);
}

View File

@@ -79,7 +79,7 @@ export function ToolsPage() {
<span className="text-[var(--p-muted)]">Big trust.</span>
</h1>
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
{pluginRegistry.length} utilities for files, text and code running entirely in your browser. No upload. No account. No server.
{pluginRegistry.length} utilities for files, text and code running entirely in your browser. No tool-data upload. No account.
</p>
</div>