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:
+50
-9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user