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
+28 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see
@@ -10,6 +11,7 @@ import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null);
@@ -68,14 +70,14 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg("Enter a positive amount.");
setMoveMsg(t("shift.enterPositive"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
setMoveAmount("");
setMoveReason("");
setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`);
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
@@ -84,27 +86,28 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Shift:</strong>{" "}
<strong>{t("shift.label")}</strong>{" "}
{startedAt ? (
<>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
<span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
{new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}>
{busy ? "Ending…" : "End shift"}
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</>
) : (
<>
<span style={{ color: "#777" }}>not started</span>{" "}
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
<button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"}
{busy ? t("shift.starting") : t("shift.startShift")}
</button>
</>
)}
{/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && (
<div style={{ marginTop: "0.5rem", color: "#555" }}>
Drawer: <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>}
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
</div>
)}
@@ -114,24 +117,24 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{isAdmin && (
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
Drawer cash (admin) — load or remove the float
{t("shift.drawerCashAdmin")}
</div>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)}
placeholder="amount"
placeholder={t("shift.amount")}
inputMode="decimal"
style={{ width: 90 }}
/>
<input
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
placeholder="reason (e.g. opening float)"
placeholder={t("shift.reasonPlaceholder")}
style={{ flex: 1, minWidth: 140 }}
/>
<button type="button" onClick={() => move(1)}>Load +</button>
<button type="button" onClick={() => move(-1)}>Remove −</button>
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
</div>
@@ -139,20 +142,20 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
<div>Payments: {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div>
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div>
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div>
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div>
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
<div>{t("shift.payments")} {report.paymentCount}</div>
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>
Expected drawer: {money(report.expectedDrawerMinor, report.currency)}
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
{report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
</div>
</div>
)}