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
+21 -13
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js";
@@ -14,13 +15,14 @@ import { Panel } from "./ui/Panel.js";
// No payment → no Open barrier button (the no-unpaid-bypass rule).
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { label: string; cls: string } {
if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" };
if (s.paidAt) return { label: "paid", cls: "text-term-green" };
return { label: "unpaid", cls: "text-term-amber" };
function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: qk.activeSessions,
@@ -48,7 +50,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? "barrier opened" : r.reason ?? "open manually",
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
@@ -57,13 +59,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return (
<Panel
title="Active sessions"
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{sessions.length} inside</span>}
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{sessions.length === 0 ? (
<div className="text-term-muted">{isLoading ? "loading…" : "no active sessions."}</div>
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
) : (
sessions.map((s) => {
const badge = statusBadge(s);
@@ -77,12 +83,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title="Open pay / exit"
title={t("booth.openPayExit")}
>
<span className="text-term-text">{s.identity}</span>
<span className="text-term-muted">in {formatTime(s.enteredAt)}</span>
<span className="text-term-muted">
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{badge.label}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button>
{/* Open barrier — PAID sessions only (no payment, no button). */}
@@ -92,9 +100,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
disabled={reopen.isPending}
onClick={() => handleReopen(s)}
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
title="Human-intervention barrier open (audited)"
title={t("booth.openBarrierTitle")}
>
Open barrier
{t("booth.openBarrier")}
</button>
) : (
<span className="w-[88px] shrink-0" />