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
+38 -33
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -22,6 +23,7 @@ import { SnapshotStrip } from "./ui/SnapshotStrip.js";
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
@@ -52,13 +54,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
setPhase("finishing");
if (voucher) {
const r = await printVoucher(identity);
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
setResult(
r.opened
? "Paid — barrier opened. Car may exit."
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
);
}
// Refresh the live views.
@@ -81,25 +83,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
Ticket {identity}
{t("pay.ticket")} {identity}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{session.isLoading && <div className="text-term-muted">looking up…</div>}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
No session found for this ticket.
{t("pay.noSessionFound")}
</div>
)}
{s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
This session is already closed (exited {formatTime(s.exitedAt)}).
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div>
)}
@@ -107,25 +109,28 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label="Entry" value={formatTime(s.enteredAt)} />
<Row label="Now" value={formatTime(new Date().toISOString())} />
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row
label="Status"
value={alreadyPaid ? "PAID" : "UNPAID"}
label={t("pay.duration")}
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
/>
<Row
label={t("pay.statusLabel")}
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Total</span>
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
<span className="text-3xl font-bold text-term-cyan">
{s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? "paid"
: "no tariff"}
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
@@ -137,19 +142,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
{(["cash", "card"] as const).map((t) => (
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={t}
key={tn}
type="button"
onClick={() => setTender(t)}
onClick={() => setTender(tn)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === t
tender === tn
? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
{t}
{t(`pay.${tn}`)}
</button>
))}
</div>
@@ -162,8 +167,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
Printo biletë dalje
<span className="text-term-muted">(customer self-exits at the exit)</span>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
</>
)}
@@ -181,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
Close
{t("common.close")}
</button>
) : (
<>
@@ -190,7 +195,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
>
Cancel
{t("common.cancel")}
</button>
<button
type="button"
@@ -199,18 +204,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? "taking payment…"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? "printing voucher…"
: "opening…"
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? "Print voucher"
: "Open barrier"
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? "Pay + print voucher"
: "Pay + open barrier"}
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
</>
)}