Switch analytics privacy controls to opt-out
This commit is contained in:
@@ -6,6 +6,7 @@ VITE_PRIVACY_EMAIL=privacy@example.com
|
|||||||
VITE_PRIVACY_EFFECTIVE_DATE=2026-06-07
|
VITE_PRIVACY_EFFECTIVE_DATE=2026-06-07
|
||||||
VITE_ANALYTICS_PROVIDER_NAME=Umami
|
VITE_ANALYTICS_PROVIDER_NAME=Umami
|
||||||
VITE_ANALYTICS_SCRIPT_URL=https://u.achraf.app/script.js
|
VITE_ANALYTICS_SCRIPT_URL=https://u.achraf.app/script.js
|
||||||
|
VITE_ANALYTICS_HOST_URL=https://u.achraf.app
|
||||||
VITE_ANALYTICS_WEBSITE_ID=89d0f8d2-5b59-438c-b1f6-ab2655e97f6e
|
VITE_ANALYTICS_WEBSITE_ID=89d0f8d2-5b59-438c-b1f6-ab2655e97f6e
|
||||||
VITE_ANALYTICS_HOST=u.achraf.app
|
VITE_ANALYTICS_HOST=u.achraf.app
|
||||||
VITE_ANALYTICS_HOST_COUNTRY=France
|
VITE_ANALYTICS_HOST_COUNTRY=France
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Plimi is a modern, modular utility suite that executes tool processing directly inside your browser. Tool inputs and outputs are not uploaded by Plimi.
|
Plimi is a modern, modular utility suite that executes tool processing directly inside your browser. Tool inputs and outputs are not uploaded by Plimi.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Privacy-First Design**: Tool processing is offline-capable. Optional audience analytics load only after consent.
|
- **Privacy-First Design**: Tool processing is offline-capable. Audience measurement is self-hosted, narrow in scope, and can be disabled locally.
|
||||||
- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema.
|
- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema.
|
||||||
- **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads.
|
- **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads.
|
||||||
- **High Performance**: Built with React, Vite, and Tailwind CSS v4.
|
- **High Performance**: Built with React, Vite, and Tailwind CSS v4.
|
||||||
@@ -57,13 +57,14 @@ Environment:
|
|||||||
- `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice.
|
- `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice.
|
||||||
- `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests.
|
- `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests.
|
||||||
- `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice.
|
- `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice.
|
||||||
- `VITE_ANALYTICS_SCRIPT_URL`, `VITE_ANALYTICS_WEBSITE_ID`: Umami tracker configuration.
|
- `VITE_ANALYTICS_SCRIPT_URL`, `VITE_ANALYTICS_HOST_URL`, `VITE_ANALYTICS_WEBSITE_ID`: Umami tracker configuration.
|
||||||
- `VITE_ANALYTICS_HOST`, `VITE_ANALYTICS_HOST_COUNTRY`: analytics recipient and hosting location disclosed to visitors.
|
- `VITE_ANALYTICS_HOST`, `VITE_ANALYTICS_HOST_COUNTRY`: analytics recipient and hosting location disclosed to visitors.
|
||||||
- `VITE_ANALYTICS_RETENTION_MONTHS`: disclosed analytics retention period. Configure Umami/database deletion to match it.
|
- `VITE_ANALYTICS_RETENTION_MONTHS`: disclosed analytics retention period. Configure Umami/database deletion to match it.
|
||||||
- `PLIMI_PORT`: host port used by Docker Compose, default `8080`.
|
- `PLIMI_PORT`: host port used by Docker Compose, default `8080`.
|
||||||
|
|
||||||
Analytics is consent-first. The tracker is injected only after acceptance and is
|
Analytics is self-hosted and enabled by default for audience measurement. The
|
||||||
configured to exclude URL search parameters and hashes and honor Do Not Track.
|
tracker excludes URL search parameters and hashes, honors Do Not Track, and can
|
||||||
|
be disabled locally through the privacy controls.
|
||||||
The privacy page is available at `/privacy`; deployers must replace all example
|
The privacy page is available at `/privacy`; deployers must replace all example
|
||||||
controller and hosting values before publishing.
|
controller and hosting values before publishing.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ write_entry VITE_PRIVACY_EMAIL "privacy@example.com"
|
|||||||
write_entry VITE_PRIVACY_EFFECTIVE_DATE "2026-06-07"
|
write_entry VITE_PRIVACY_EFFECTIVE_DATE "2026-06-07"
|
||||||
write_entry VITE_ANALYTICS_PROVIDER_NAME "Umami"
|
write_entry VITE_ANALYTICS_PROVIDER_NAME "Umami"
|
||||||
write_entry VITE_ANALYTICS_SCRIPT_URL "https://u.achraf.app/script.js"
|
write_entry VITE_ANALYTICS_SCRIPT_URL "https://u.achraf.app/script.js"
|
||||||
|
write_entry VITE_ANALYTICS_HOST_URL "https://u.achraf.app"
|
||||||
write_entry VITE_ANALYTICS_WEBSITE_ID "89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
|
write_entry VITE_ANALYTICS_WEBSITE_ID "89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
|
||||||
write_entry VITE_ANALYTICS_HOST "u.achraf.app"
|
write_entry VITE_ANALYTICS_HOST "u.achraf.app"
|
||||||
write_entry VITE_ANALYTICS_HOST_COUNTRY "France"
|
write_entry VITE_ANALYTICS_HOST_COUNTRY "France"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
|
import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { choice, openSettings } = useAnalyticsConsent();
|
const { enabled, toggleAnalytics } = useAnalyticsConsent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
|
<footer className="border-t border-[var(--p-border)] px-6 py-6 md:px-14">
|
||||||
@@ -18,10 +18,11 @@ export function Footer() {
|
|||||||
Privacy & legal
|
Privacy & legal
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={openSettings}
|
onClick={toggleAnalytics}
|
||||||
|
aria-pressed={!enabled}
|
||||||
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
|
className="text-[var(--p-muted)] hover:text-[var(--p-text)]"
|
||||||
>
|
>
|
||||||
Analytics settings ({choice === "granted" ? "on" : "off"})
|
Analytics {enabled ? "on" : "off"}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface PlimiRuntimeConfig {
|
|||||||
VITE_PRIVACY_EFFECTIVE_DATE?: string;
|
VITE_PRIVACY_EFFECTIVE_DATE?: string;
|
||||||
VITE_ANALYTICS_PROVIDER_NAME?: string;
|
VITE_ANALYTICS_PROVIDER_NAME?: string;
|
||||||
VITE_ANALYTICS_SCRIPT_URL?: string;
|
VITE_ANALYTICS_SCRIPT_URL?: string;
|
||||||
|
VITE_ANALYTICS_HOST_URL?: string;
|
||||||
VITE_ANALYTICS_WEBSITE_ID?: string;
|
VITE_ANALYTICS_WEBSITE_ID?: string;
|
||||||
VITE_ANALYTICS_HOST?: string;
|
VITE_ANALYTICS_HOST?: string;
|
||||||
VITE_ANALYTICS_HOST_COUNTRY?: string;
|
VITE_ANALYTICS_HOST_COUNTRY?: string;
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
hasAnalyticsConfiguration,
|
hasAnalyticsConfiguration,
|
||||||
privacyConfig,
|
privacyConfig,
|
||||||
} from "./privacy-config";
|
} from "./privacy-config";
|
||||||
import {
|
import {
|
||||||
AnalyticsConsentContext,
|
AnalyticsConsentContext,
|
||||||
type AnalyticsConsentValue,
|
type AnalyticsPreferenceValue,
|
||||||
type ConsentChoice,
|
|
||||||
} from "./analytics-consent-context";
|
} from "./analytics-consent-context";
|
||||||
|
|
||||||
const CONSENT_KEY = "plimi.analytics-consent.v1";
|
|
||||||
const SCRIPT_ID = "plimi-umami-script";
|
const SCRIPT_ID = "plimi-umami-script";
|
||||||
|
const UMAMI_DISABLED_KEY = "umami.disabled";
|
||||||
|
|
||||||
function storedChoice(): ConsentChoice {
|
function analyticsEnabledInitially(): boolean {
|
||||||
const value = window.localStorage.getItem(CONSENT_KEY);
|
return window.localStorage.getItem(UMAMI_DISABLED_KEY) !== "1";
|
||||||
return value === "granted" || value === "denied" ? value : "pending";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAnalyticsScript() {
|
function removeAnalyticsScript() {
|
||||||
@@ -36,6 +32,7 @@ function loadAnalyticsScript() {
|
|||||||
script.defer = true;
|
script.defer = true;
|
||||||
script.src = privacyConfig.analyticsScriptUrl;
|
script.src = privacyConfig.analyticsScriptUrl;
|
||||||
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
|
script.dataset.websiteId = privacyConfig.analyticsWebsiteId;
|
||||||
|
script.dataset.hostUrl = privacyConfig.analyticsHostUrl;
|
||||||
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
|
script.dataset.domains = new URL(privacyConfig.siteUrl).hostname;
|
||||||
script.dataset.doNotTrack = "true";
|
script.dataset.doNotTrack = "true";
|
||||||
script.dataset.excludeSearch = "true";
|
script.dataset.excludeSearch = "true";
|
||||||
@@ -43,145 +40,50 @@ function loadAnalyticsScript() {
|
|||||||
document.head.appendChild(script);
|
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({
|
export function AnalyticsConsentProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [choice, setChoice] = useState<ConsentChoice>(storedChoice);
|
const [enabled, setEnabled] = useState(analyticsEnabledInitially);
|
||||||
const [isSettingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (choice === "granted") {
|
if (enabled) {
|
||||||
loadAnalyticsScript();
|
loadAnalyticsScript();
|
||||||
} else {
|
} else {
|
||||||
removeAnalyticsScript();
|
removeAnalyticsScript();
|
||||||
}
|
}
|
||||||
}, [choice]);
|
}, [enabled]);
|
||||||
|
|
||||||
const saveChoice = useCallback(
|
const value = useMemo<AnalyticsPreferenceValue>(
|
||||||
(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,
|
enabled,
|
||||||
isSettingsOpen,
|
disableAnalytics: () => {
|
||||||
accept: () => saveChoice("granted"),
|
window.localStorage.setItem(UMAMI_DISABLED_KEY, "1");
|
||||||
reject: () => saveChoice("denied"),
|
setEnabled(false);
|
||||||
openSettings: () => setSettingsOpen(true),
|
window.location.reload();
|
||||||
closeSettings: () => setSettingsOpen(false),
|
},
|
||||||
|
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 (
|
return (
|
||||||
<AnalyticsConsentContext.Provider value={value}>
|
<AnalyticsConsentContext.Provider value={value}>
|
||||||
{children}
|
{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>
|
</AnalyticsConsentContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
export type ConsentChoice = "pending" | "granted" | "denied";
|
export interface AnalyticsPreferenceValue {
|
||||||
|
enabled: boolean;
|
||||||
export interface AnalyticsConsentValue {
|
disableAnalytics: () => void;
|
||||||
choice: ConsentChoice;
|
enableAnalytics: () => void;
|
||||||
isSettingsOpen: boolean;
|
toggleAnalytics: () => void;
|
||||||
accept: () => void;
|
|
||||||
reject: () => void;
|
|
||||||
openSettings: () => void;
|
|
||||||
closeSettings: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnalyticsConsentContext =
|
export const AnalyticsConsentContext =
|
||||||
createContext<AnalyticsConsentValue | null>(null);
|
createContext<AnalyticsPreferenceValue | null>(null);
|
||||||
|
|
||||||
export function useAnalyticsConsent(): AnalyticsConsentValue {
|
export function useAnalyticsConsent(): AnalyticsPreferenceValue {
|
||||||
const context = useContext(AnalyticsConsentContext);
|
const context = useContext(AnalyticsConsentContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const privacyConfig = {
|
|||||||
"VITE_ANALYTICS_SCRIPT_URL",
|
"VITE_ANALYTICS_SCRIPT_URL",
|
||||||
"https://u.achraf.app/script.js"
|
"https://u.achraf.app/script.js"
|
||||||
),
|
),
|
||||||
|
analyticsHostUrl: runtimeConfigValue(
|
||||||
|
"VITE_ANALYTICS_HOST_URL",
|
||||||
|
"https://u.achraf.app"
|
||||||
|
),
|
||||||
analyticsWebsiteId: runtimeConfigValue(
|
analyticsWebsiteId: runtimeConfigValue(
|
||||||
"VITE_ANALYTICS_WEBSITE_ID",
|
"VITE_ANALYTICS_WEBSITE_ID",
|
||||||
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
|
"89d0f8d2-5b59-438c-b1f6-ab2655e97f6e"
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ export function HowItWorksPage() {
|
|||||||
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
|
<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
|
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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function Section({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PrivacyPage() {
|
export function PrivacyPage() {
|
||||||
const { choice, openSettings } = useAnalyticsConsent();
|
const { enabled, enableAnalytics, disableAnalytics } = useAnalyticsConsent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[920px] flex-col gap-6 pb-16 animate-plimi-slide">
|
<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
|
privacy notice & legal information
|
||||||
</span>
|
</span>
|
||||||
<h1 className="m-0 font-sans text-4xl font-bold leading-[1.03] tracking-tight text-[var(--p-text)] md:text-[52px]">
|
<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>
|
</h1>
|
||||||
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
|
<p className="m-0 max-w-[760px] text-base leading-relaxed text-[var(--p-muted)]">
|
||||||
Effective {privacyConfig.policyEffectiveDate}. This notice explains
|
Effective {privacyConfig.policyEffectiveDate}. This notice explains
|
||||||
@@ -72,55 +72,73 @@ export function PrivacyPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Optional analytics">
|
<Section title="Audience measurement">
|
||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Analytics are disabled until you consent. If accepted, the site loads{" "}
|
Plimi uses a self-hosted {privacyConfig.analyticsProvider} audience
|
||||||
{privacyConfig.analyticsProvider} from {privacyConfig.analyticsHost}.
|
measurement script from {privacyConfig.analyticsHost}. It measures
|
||||||
It may process the visited route, referrer, timestamp, browser,
|
page visits using cookieless analytics and may process the visited
|
||||||
operating system, device type, screen size, language, and an
|
route, timestamp, browser, operating system, device type, screen
|
||||||
approximate location derived from network information.
|
size, language, referrer, and an approximate country derived from
|
||||||
|
network information.
|
||||||
</p>
|
</p>
|
||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Purpose: aggregate audience measurement and improvement of Plimi.
|
Purpose: aggregate audience measurement and improvement of Plimi.
|
||||||
Legal basis: your consent under GDPR Article 6(1)(a). Analytics data
|
Plimi does not use analytics for advertising, profiling, sharing with
|
||||||
is configured for a retention period of{" "}
|
data brokers, or cross-site tracking. Analytics data is configured
|
||||||
|
for a retention period of{" "}
|
||||||
{privacyConfig.analyticsRetentionMonths} months and is hosted in{" "}
|
{privacyConfig.analyticsRetentionMonths} months and is hosted in{" "}
|
||||||
{privacyConfig.analyticsHostCountry}.
|
{privacyConfig.analyticsHostCountry}.
|
||||||
</p>
|
</p>
|
||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Plimi does not send tool inputs, uploaded file contents, outputs,
|
Plimi does not send tool inputs, uploaded file contents, outputs,
|
||||||
names, email addresses, or account identifiers to analytics. The
|
names, email addresses, account identifiers, custom analytics events,
|
||||||
tracker is configured to exclude URL search parameters and hashes and
|
or `umami.identify()` data. The tracker excludes URL search
|
||||||
to honor browser Do Not Track.
|
parameters and hash fragments and honors browser Do Not Track.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
<button
|
{enabled ? (
|
||||||
onClick={openSettings}
|
<button
|
||||||
className="rounded-[10px] bg-[var(--p-accent)] px-4 py-2.5 font-sans text-sm font-semibold text-[var(--p-accent-ink)]"
|
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)]"
|
||||||
Change analytics choice
|
>
|
||||||
</button>
|
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)]">
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Section title="Local storage and essential preferences">
|
<Section title="Local storage and essential preferences">
|
||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Plimi stores your theme preference and analytics choice in browser
|
Plimi stores your theme preference and analytics opt-out preference in
|
||||||
local storage. These values are necessary to remember the settings
|
browser local storage. These values are used only to remember your
|
||||||
you selected. Plimi does not use advertising cookies.
|
settings. Plimi does not use analytics cookies, advertising cookies,
|
||||||
|
or marketing pixels.
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Recipients and transfers">
|
<Section title="Recipients and transfers">
|
||||||
<p className="m-0">
|
<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
|
operator of {privacyConfig.analyticsHost} and any infrastructure
|
||||||
providers used to host it. The controller must maintain appropriate
|
providers used to host it. Plimi does not load other non-essential
|
||||||
processor agreements and transfer safeguards where a provider
|
trackers, third-party embeds, advertising pixels, or cross-site
|
||||||
processes data outside the EEA.
|
analytics services.
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -128,8 +146,8 @@ export function PrivacyPage() {
|
|||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Depending on applicable law, you may request access, correction,
|
Depending on applicable law, you may request access, correction,
|
||||||
deletion, restriction, or portability of your personal data. You may
|
deletion, restriction, or portability of your personal data. You may
|
||||||
withdraw analytics consent at any time through Analytics settings;
|
opt out of analytics at any time through the controls on this page or
|
||||||
withdrawal does not affect prior lawful processing.
|
in the footer.
|
||||||
</p>
|
</p>
|
||||||
<p className="m-0">
|
<p className="m-0">
|
||||||
Contact {privacyConfig.privacyEmail} to exercise a right. You may also
|
Contact {privacyConfig.privacyEmail} to exercise a right. You may also
|
||||||
|
|||||||
Reference in New Issue
Block a user