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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user