add GDPR Banner
This commit is contained in:
@@ -2,14 +2,12 @@ import { Link } from "react-router-dom";
|
||||
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
|
||||
|
||||
export function Footer() {
|
||||
const { enabled, toggleAnalytics } = useAnalyticsConsent();
|
||||
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>
|
||||
<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"
|
||||
@@ -18,11 +16,10 @@ export function Footer() {
|
||||
Privacy & legal
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleAnalytics}
|
||||
aria-pressed={!enabled}
|
||||
onClick={openSettings}
|
||||
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
|
||||
>
|
||||
Analytics {enabled ? "on" : "off"}
|
||||
Analytics settings ({choice === "granted" ? "on" : "off"})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
hasAnalyticsConfiguration,
|
||||
privacyConfig,
|
||||
} from "./privacy-config";
|
||||
import {
|
||||
AnalyticsConsentContext,
|
||||
type AnalyticsPreferenceValue,
|
||||
type AnalyticsConsentValue,
|
||||
type ConsentChoice,
|
||||
} from "./analytics-consent-context";
|
||||
|
||||
const CONSENT_KEY = "plimi.analytics-consent.v1";
|
||||
const LEGACY_DISABLED_KEY = "umami.disabled";
|
||||
const SCRIPT_ID = "plimi-umami-script";
|
||||
const UMAMI_DISABLED_KEY = "umami.disabled";
|
||||
|
||||
function analyticsEnabledInitially(): boolean {
|
||||
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1";
|
||||
function storedChoice(): ConsentChoice {
|
||||
try {
|
||||
const value = window.localStorage.getItem(CONSENT_KEY);
|
||||
if (value === "granted" || value === "denied") return value;
|
||||
|
||||
if (window.localStorage.getItem(LEGACY_DISABLED_KEY) === "1") {
|
||||
return "denied";
|
||||
}
|
||||
} catch {
|
||||
// Treat unavailable storage as no consent.
|
||||
}
|
||||
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function removeAnalyticsScript() {
|
||||
@@ -40,50 +55,156 @@ 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 [enabled, setEnabled] = useState(analyticsEnabledInitially);
|
||||
const [choice, setChoice] = useState<ConsentChoice>(storedChoice);
|
||||
const [isSettingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
if (choice === "granted") {
|
||||
loadAnalyticsScript();
|
||||
} else {
|
||||
removeAnalyticsScript();
|
||||
}
|
||||
}, [enabled]);
|
||||
}, [choice]);
|
||||
|
||||
const value = useMemo<AnalyticsPreferenceValue>(
|
||||
() => ({
|
||||
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");
|
||||
const saveChoice = useCallback(
|
||||
(nextChoice: Exclude<ConsentChoice, "pending">) => {
|
||||
const wasGranted = choice === "granted";
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(CONSENT_KEY, nextChoice);
|
||||
if (nextChoice === "denied") {
|
||||
window.localStorage.setItem(LEGACY_DISABLED_KEY, "1");
|
||||
} else {
|
||||
window.localStorage.removeItem(UMAMI_DISABLED_KEY);
|
||||
window.localStorage.removeItem(LEGACY_DISABLED_KEY);
|
||||
}
|
||||
setEnabled(!enabled);
|
||||
} catch {
|
||||
// The in-memory choice still applies for the current page.
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
[enabled]
|
||||
[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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface AnalyticsPreferenceValue {
|
||||
enabled: boolean;
|
||||
disableAnalytics: () => void;
|
||||
enableAnalytics: () => void;
|
||||
toggleAnalytics: () => void;
|
||||
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<AnalyticsPreferenceValue | null>(null);
|
||||
createContext<AnalyticsConsentValue | null>(null);
|
||||
|
||||
export function useAnalyticsConsent(): AnalyticsPreferenceValue {
|
||||
export function useAnalyticsConsent(): AnalyticsConsentValue {
|
||||
const context = useContext(AnalyticsConsentContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
|
||||
@@ -21,7 +21,7 @@ function Section({
|
||||
}
|
||||
|
||||
export function PrivacyPage() {
|
||||
const { enabled, enableAnalytics, disableAnalytics } = useAnalyticsConsent();
|
||||
const { choice, openSettings } = 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 stay local. Analytics stay narrow.
|
||||
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
|
||||
@@ -72,14 +72,15 @@ export function PrivacyPage() {
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Audience measurement">
|
||||
<Section title="Optional analytics">
|
||||
<p className="m-0">
|
||||
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.
|
||||
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 country derived from the IP address. Umami states that it
|
||||
does not store the IP address, but uses request and device information
|
||||
to generate a session identifier.
|
||||
</p>
|
||||
<p className="m-0">
|
||||
Purpose: aggregate audience measurement and improvement of Plimi.
|
||||
@@ -95,37 +96,40 @@ export function PrivacyPage() {
|
||||
or `umami.identify()` data. The tracker excludes URL search
|
||||
parameters and hash fragments and honors browser Do Not Track.
|
||||
</p>
|
||||
<p className="m-0">
|
||||
The legal basis is your consent under Article 6(1)(a) GDPR. Umami is
|
||||
not loaded and no analytics request is sent unless you select
|
||||
"Accept analytics". You may withdraw consent at any time,
|
||||
without affecting processing that occurred before withdrawal.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
{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>
|
||||
)}
|
||||
<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)]">
|
||||
Analytics are currently {enabled ? "enabled" : "disabled"} on this
|
||||
browser
|
||||
Current choice:{" "}
|
||||
{choice === "granted"
|
||||
? "accepted"
|
||||
: choice === "denied"
|
||||
? "rejected"
|
||||
: "not chosen"}
|
||||
</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.
|
||||
Your choice is stored locally as{" "}
|
||||
<code className="text-[var(--p-text)]">
|
||||
localStorage["plimi.analytics-consent.v1"]
|
||||
</code>
|
||||
. You can change or withdraw it at any time using these controls.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Local storage and essential preferences">
|
||||
<p className="m-0">
|
||||
Plimi stores your theme preference and analytics opt-out preference in
|
||||
Plimi stores your theme preference and analytics consent 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.
|
||||
|
||||
Reference in New Issue
Block a user