Privacy policy, terms of service, cookie policy + a cookie notice
Three plain-language documents in both app languages (lib/legal.ts, rendered by components/LegalPage.tsx): /privacy describes the local-first storage, the key never leaving the browser and the three opt-in server copies (sync, reminders, reports); /terms covers the GPL-3.0-only software, your own Ollama key, fair use and liability; /cookies states what is true — no cookies at all (no Set-Cookie, no document.cookie), strictly-necessary local storage instead, no tracking. A cookie notice tells every new browser the same thing once and remembers the dismissal (freezmo.consent.v1). It sits at z-44: below the immersive study feeds so it can never cover a session, above the phone tab bar. Settings links all three documents. The regression suite checks the pages render with real sections and that dismissing the notice sticks. Version 0.1.7, SW -> freezmo-v53.
This commit is contained in:
parent
df34337b4a
commit
464781a7fd
13 changed files with 583 additions and 2 deletions
62
components/CookieConsent.tsx
Normal file
62
components/CookieConsent.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
const STORAGE_KEY = "freezmo.consent.v1";
|
||||
|
||||
/**
|
||||
* The cookie notice. There are no cookies (see /cookies), so this is an
|
||||
* honest "here is what we store in your browser" line, dismissed once and
|
||||
* remembered. It sits below the immersive feeds in the stack (z-44) so it can
|
||||
* never cover a study session, and above the phone tab bar.
|
||||
*/
|
||||
export function CookieConsent() {
|
||||
const { t } = useI18n();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setVisible(window.localStorage.getItem(STORAGE_KEY) === null);
|
||||
} catch {
|
||||
/* private mode: no storage, no banner */
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ at: Date.now() }));
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] z-[44] flex justify-center px-4 sm:bottom-6">
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("consent.title")}
|
||||
className="card pointer-events-auto flex max-w-xl flex-wrap items-center gap-3 p-3"
|
||||
>
|
||||
<p className="min-w-0 flex-1 text-[12.5px] leading-snug text-[var(--text-muted)]">
|
||||
{t("consent.body")}{" "}
|
||||
<Link href="/cookies" className="font-semibold text-[var(--text)] underline decoration-dotted">
|
||||
{t("consent.link")}
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="btn-primary touch-target h-10 shrink-0 rounded-xl px-4 text-[13px] font-semibold"
|
||||
>
|
||||
{t("consent.ok")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/LegalPage.tsx
Normal file
68
components/LegalPage.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { LEGAL, type LegalSlug } from "@/lib/legal";
|
||||
|
||||
/**
|
||||
* Renders one legal document in the current language (the text lives in
|
||||
* lib/legal.ts, both languages side by side so they cannot drift apart).
|
||||
*/
|
||||
export function LegalPage({ slug }: { slug: LegalSlug }) {
|
||||
const { locale, t } = useI18n();
|
||||
const document = LEGAL[slug][locale === "cs" ? "cs" : "en"];
|
||||
|
||||
return (
|
||||
<main className="frost-bg min-h-dvh px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
|
||||
<article className="mx-auto w-full max-w-2xl py-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("legal.updated", { when: document.updated })}
|
||||
</p>
|
||||
<h1 className="mt-1 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||
{document.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-relaxed text-[var(--text-muted)]">{document.intro}</p>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
{document.sections.map((section) => (
|
||||
<section key={section.heading} className="card space-y-2 p-4 sm:p-5">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text)]">{section.heading}</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{section.list ? (
|
||||
<ul className="list-disc space-y-1.5 pl-5 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
{section.list.map((entry) => (
|
||||
<li key={entry}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[12px] text-[var(--text-subtle)]">{document.footer}</p>
|
||||
|
||||
<nav className="mt-6 flex flex-wrap gap-3 text-[13px] font-semibold">
|
||||
{(["privacy", "terms", "cookies"] as const)
|
||||
.filter((other) => other !== slug)
|
||||
.map((other) => (
|
||||
<Link
|
||||
key={other}
|
||||
href={`/${other}`}
|
||||
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
{t(`legal.${other}`)}
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/" className="rounded-xl px-3 py-2 text-[var(--text-muted)] underline decoration-dotted">
|
||||
{t("feed.back")}
|
||||
</Link>
|
||||
</nav>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
|
|
@ -529,6 +530,24 @@ export function SettingsDialog({
|
|||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
{/* Legal: the same three documents the cookie banner
|
||||
links to, always reachable from Settings. */}
|
||||
<div className="pt-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("settings.legal")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] font-semibold">
|
||||
<Link href="/privacy" className="text-[var(--text)] underline decoration-dotted">
|
||||
{t("legal.privacy")}
|
||||
</Link>
|
||||
<Link href="/terms" className="text-[var(--text)] underline decoration-dotted">
|
||||
{t("legal.terms")}
|
||||
</Link>
|
||||
<Link href="/cookies" className="text-[var(--text)] underline decoration-dotted">
|
||||
{t("legal.cookies")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{health?.mode === "direct-cloud" && health.needsKey ? (
|
||||
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("health.createKey.before")}{" "}
|
||||
|
|
@ -808,6 +827,19 @@ export function SettingsDialog({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Legal: the same three documents the cookie banner links to. */}
|
||||
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] font-semibold">
|
||||
<Link href="/privacy" className="text-[var(--text-muted)] underline decoration-dotted">
|
||||
{t("legal.privacy")}
|
||||
</Link>
|
||||
<Link href="/terms" className="text-[var(--text-muted)] underline decoration-dotted">
|
||||
{t("legal.terms")}
|
||||
</Link>
|
||||
<Link href="/cookies" className="text-[var(--text-muted)] underline decoration-dotted">
|
||||
{t("legal.cookies")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* keeps real content out of the 36px fade strip at the bottom */}
|
||||
<div className="h-9" aria-hidden />
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue