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
+26 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
@@ -7,17 +8,19 @@ import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type S
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order, with labels + placeholders.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
{ key: "address", label: "Address", multiline: true },
{ key: "phone", label: "Phone" },
{ key: "email", label: "Email" },
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
// (resolved at render); only `address` is multiline.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
{ key: "phone", labelKey: "site.fieldPhone" },
{ key: "email", labelKey: "site.fieldEmail" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
@@ -52,7 +55,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
try {
await saveSiteConfig(patch);
reload();
setMsg("Saved.");
setMsg(t("site.saved"));
} catch (e) {
setMsg((e as Error).message);
}
@@ -60,25 +63,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Occupancy:</strong>{" "}
<strong>{t("site.occupancy")}</strong>{" "}
{occ == null ? (
"…"
) : (
<>
<span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
{occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span>
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
)}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label>
Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
{t("site.capacityLabel")}{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input
@@ -86,35 +89,33 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
Print exit ticket by default
<span style={{ color: "#888", fontSize: "0.8rem" }}>
(booth far from exit → customer self-exits with a voucher)
</span>
{t("site.printExitDefault")}
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts)
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label}
{t(labelKey)}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={placeholder}
placeholder={phKey ? t(phKey) : undefined}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder}
placeholder={phKey ? t(phKey) : undefined}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={save}>{t("site.save")}</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
</div>