// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be // reachable unauthenticated — Google's OAuth consent screen links to them and reviewers // fetch them directly — so they render outside the authenticated App tree (see main.tsx), // with no react-query provider: the operator contact is fetched with a bare `send` below. // // Both pages exist in Hungarian and English, like the rest of the UI. The prose is a document, not // a set of UI strings, so each language's body is its own component (see PrivacyPolicy/Terms) and // `legalBodies.contract.dom.test.tsx` holds the two to the same sections; the chrome around them is i18n. import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { setLanguage, type LangCode } from "../../i18n"; import { formatDate } from "../../lib/format"; import { send } from "../../lib/http"; import LanguageSwitcher from "../ui/LanguageSwitcher"; import { SURFACE } from "../ui/surfaces"; // The day the text last changed materially — bump it with every edit of either body. const LAST_UPDATED = "2026-10-04"; // Contact shown on the legal pages: the instance operator's configured admin email, from the // public /auth/config. One shared fetch across the page; null when the operator set none. let contactPromise: Promise | null = null; function fetchOperatorContact(): Promise { if (!contactPromise) { contactPromise = send("/auth/config") .then((r) => (r.ok ? r.json() : null)) .then((c) => (c?.operator_contact as string | undefined) ?? null) .catch(() => null); } return contactPromise; } function useOperatorContact(): string | null { const [contact, setContact] = useState(null); useEffect(() => { let alive = true; void fetchOperatorContact().then((c) => alive && setContact(c)); // never rejects: null on failure return () => { alive = false; }; }, []); return contact; } // The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset. export function ContactEmail() { const { t } = useTranslation(); const email = useOperatorContact(); if (!email) return <>{t("legal.operatorFallback")}; return ( {email} ); } export function LegalLink({ href, children }: { href: string; children: ReactNode }) { return ( {children} ); } export default function LegalLayout({ title, children }: { title: string; children: ReactNode }) { const { t, i18n } = useTranslation(); const contact = useOperatorContact(); // A link to a section (`/privacy#diagnostics`): the browser tries to scroll to the fragment before // this lazily loaded page has rendered it, so jump there once the section exists. useEffect(() => { const id = decodeURIComponent(window.location.hash.slice(1)); if (id) document.getElementById(id)?.scrollIntoView(); }, []); // The tab names the document, like every page of the app — a bookmark or a tab strip read just // "Siftlode" (prod UAT 2026-09-19). useEffect(() => { document.title = `${title} · Siftlode`; }, [title]); return (
Siftlode {/* The landing page's own picker, so the public pages switch language one way. */}

{title}

{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}

{children}
); } /** A section heading. `id` is required: it is the anchor a link can point at (Settings → * Diagnosztika links `/privacy#diagnostics`), and what holds the two languages to the same sections. */ export function H2({ id, children }: { id: string; children: ReactNode }) { return (

{children}

); }