A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
// 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<string | null> | null = null;
|
|
function fetchOperatorContact(): Promise<string | null> {
|
|
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<string | null>(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 (
|
|
<a href={`mailto:${email}`} className="text-accent hover:underline">
|
|
{email}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
export function LegalLink({ href, children }: { href: string; children: ReactNode }) {
|
|
return (
|
|
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="min-h-dvh bg-bg text-fg">
|
|
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
|
<div className="mb-8 flex items-center justify-between gap-4">
|
|
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
|
|
Sift<span className="text-accent">lode</span>
|
|
</a>
|
|
{/* The landing page's own picker, so the public pages switch language one way. */}
|
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
|
</div>
|
|
<div className={`${SURFACE.panel} p-8`}>
|
|
<h1 className="text-2xl font-semibold">{title}</h1>
|
|
<p className="text-xs text-muted mt-1 mb-6">
|
|
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
|
|
</p>
|
|
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
|
|
</div>
|
|
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
|
<a href="/" className="hover:text-fg transition">
|
|
{t("legal.home")}
|
|
</a>
|
|
<a href="/privacy" className="hover:text-fg transition">
|
|
{t("common.privacyPolicy")}
|
|
</a>
|
|
<a href="/terms" className="hover:text-fg transition">
|
|
{t("common.termsOfService")}
|
|
</a>
|
|
{contact && (
|
|
<a href={`mailto:${contact}`} className="hover:text-fg transition">
|
|
{t("legal.contact")}
|
|
</a>
|
|
)}
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<h2 id={id} className="text-base font-semibold text-fg pt-2 scroll-mt-6">
|
|
{children}
|
|
</h2>
|
|
);
|
|
}
|