feat(web): i18n with react-i18next — Albanian default, English second

Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
This commit is contained in:
2026-06-18 11:47:39 +02:00
parent 445bca0bf6
commit 14c83e182a
19 changed files with 840 additions and 201 deletions
+50 -9
View File
@@ -6,9 +6,11 @@ import {
Outlet,
redirect,
} from "@tanstack/react-router";
import type { SessionUser } from "./api.js";
import { logout } from "./api.js";
import { useTranslation } from "react-i18next";
import type { Lang, SessionUser } from "./api.js";
import { logout, setLanguagePref } from "./api.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
@@ -43,8 +45,46 @@ function NavLink({ to, label }: { to: string; label: string }) {
);
}
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
* and applies it immediately. Updates the router-context user so App re-syncs. */
function LanguageToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
async function pick(lang: Lang) {
if (lang === user.language) return;
setLanguage(lang); // instant UI
setUser({ ...user, language: lang });
try {
await setLanguagePref(lang); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
</button>
))}
</div>
);
}
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
const isAdmin = user?.role === "admin";
@@ -54,14 +94,15 @@ function RootLayout() {
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label="Booth" />
<NavLink to="/shift" label="Shift" />
{isAdmin && <NavLink to="/setup" label="Setup" />}
{isAdmin && <NavLink to="/tariff" label="Tariff" />}
{isAdmin && <NavLink to="/permits" label="Permits" />}
{isAdmin && <NavLink to="/site" label="Site" />}
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <LanguageToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.role}
@@ -74,7 +115,7 @@ function RootLayout() {
setUser(null);
}}
>
Log out
{t("common.logout")}
</button>
</div>
</header>