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
+24 -26
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
fetchTariff,
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
}
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -112,7 +114,7 @@ export function TariffComposer() {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff();
setState(fresh);
setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." });
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
@@ -126,44 +128,40 @@ export function TariffComposer() {
return (
<section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2>
<h2>{t("tariff.title")}</h2>
{!state?.active ? (
<p style={{ color: "#b45309" }}>
No rate card published yet — the pay station can't charge until you publish one.
</p>
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
) : (
<p style={{ color: "#555" }}>
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
{state.versions.length} version(s) in history. Publishing creates a new version; past
sessions keep their original pricing.
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
})}
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
<label>Currency</label>
<label>{t("tariff.currency")}</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
<label>Free entry grace (min)</label>
<label>{t("tariff.freeEntryGrace")}</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>Billing increment (min)</label>
<label>{t("tariff.billingIncrement")}</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
<label>Lost-ticket fee</label>
<label>{t("tariff.dailyCap")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
<label>{t("tariff.lostTicketFee")}</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>Exit walk-back grace (min)</label>
<label>{t("tariff.exitGrace")}</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last
block's bound blank for "thereafter". Price is per billing increment.
</p>
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
<th />
</tr>
</thead>
@@ -174,7 +172,7 @@ export function TariffComposer() {
<input
value={b.uptoMin}
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
style={{ width: 110 }}
/>
</td>
@@ -183,7 +181,7 @@ export function TariffComposer() {
</td>
<td>
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
Remove
{t("tariff.remove")}
</button>
</td>
</tr>
@@ -191,12 +189,12 @@ export function TariffComposer() {
</tbody>
</table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
+ Add block
{t("tariff.addBlock")}
</button>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"}
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
</div>
{msg && (