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:
Martin Dvořák 2026-10-12 21:26:55 +00:00
commit 464781a7fd
13 changed files with 583 additions and 2 deletions

68
components/LegalPage.tsx Normal file
View 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>
);
}