From 14c83e182ad49a9535efca65e7cb98ea9d030588 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 11:47:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20i18n=20with=20react-i18next=20?= =?UTF-8?q?=E2=80=94=20Albanian=20default,=20English=20second?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/web/package.json | 2 + apps/web/src/ActiveSessions.tsx | 34 +++-- apps/web/src/App.tsx | 7 + apps/web/src/BoothPayModal.tsx | 71 +++++----- apps/web/src/BoothScreen.tsx | 63 +++++---- apps/web/src/Login.tsx | 10 +- apps/web/src/PermitManager.tsx | 72 +++++----- apps/web/src/ShiftControl.tsx | 53 +++---- apps/web/src/SiteSettings.tsx | 51 +++---- apps/web/src/TariffComposer.tsx | 50 ++++--- apps/web/src/api.ts | 8 ++ apps/web/src/lib/i18n/en.ts | 218 ++++++++++++++++++++++++++++ apps/web/src/lib/i18n/index.ts | 33 +++++ apps/web/src/lib/i18n/sq.ts | 227 ++++++++++++++++++++++++++++++ apps/web/src/main.tsx | 1 + apps/web/src/router.tsx | 59 ++++++-- apps/web/src/ui/SnapshotStrip.tsx | 6 +- apps/web/src/ui/StatusDot.tsx | 12 +- pnpm-lock.yaml | 64 +++++++++ 19 files changed, 840 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/lib/i18n/en.ts create mode 100644 apps/web/src/lib/i18n/index.ts create mode 100644 apps/web/src/lib/i18n/sq.ts diff --git a/apps/web/package.json b/apps/web/package.json index a2ec161..fee9bc0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,8 +17,10 @@ "@radix-ui/react-tabs": "^1.1.15", "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.16", + "i18next": "^26.3.1", "react": "19.2.7", "react-dom": "19.2.7", + "react-i18next": "^17.0.8", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 6b9e44f..0d0e012 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { qk } from "./lib/query.js"; @@ -14,13 +15,14 @@ import { Panel } from "./ui/Panel.js"; // No payment → no Open barrier button (the no-unpaid-bypass rule). // See wiki/concepts/booth-exit-flow.md. -function statusBadge(s: ActiveSession): { label: string; cls: string } { - if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" }; - if (s.paidAt) return { label: "paid", cls: "text-term-green" }; - return { label: "unpaid", cls: "text-term-amber" }; +function statusBadge(s: ActiveSession): { key: string; cls: string } { + if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" }; + if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" }; + return { key: "booth.badgeUnpaid", cls: "text-term-amber" }; } export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { + const { t } = useTranslation(); const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: qk.activeSessions, @@ -48,7 +50,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void setReopenMsg({ id: s.identity, ok: r.opened, - text: r.opened ? "barrier opened" : r.reason ?? "open manually", + text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"), }); } catch (e) { setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); @@ -57,13 +59,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void return ( {sessions.length} inside} + title={t("booth.activeSessions")} + right={ + + {sessions.length} {t("booth.insideCount")} + + } className="min-h-0" >
{sessions.length === 0 ? ( -
{isLoading ? "loading…" : "no active sessions."}
+
{isLoading ? t("common.loading") : t("booth.noActiveSessions")}
) : ( sessions.map((s) => { const badge = statusBadge(s); @@ -77,12 +83,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void type="button" onClick={() => onPick(s.identity)} className="flex flex-1 items-center gap-3 text-left hover:text-term-amber" - title="Open pay / exit" + title={t("booth.openPayExit")} > {s.identity} - in {formatTime(s.enteredAt)} + + {t("booth.inAt")} {formatTime(s.enteredAt)} + {formatDuration(s.enteredAt, new Date().toISOString())} - {badge.label} + {t(badge.key)} {/* Open barrier — PAID sessions only (no payment, no button). */} @@ -92,9 +100,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void disabled={reopen.isPending} onClick={() => handleReopen(s)} className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50" - title="Human-intervention barrier open (audited)" + title={t("booth.openBarrierTitle")} > - Open barrier + {t("booth.openBarrier")} ) : ( diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b342991..7818abd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { fetchMe, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; import { queryClient } from "./lib/query.js"; +import { setLanguage } from "./lib/i18n/index.js"; import { router } from "./router.js"; // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands @@ -22,6 +23,12 @@ export function App() { .finally(() => setLoading(false)); }, []); + // Apply the signed-in user's preferred language whenever it resolves/changes + // (login, bootstrap, or a toggle). Albanian is the default before auth resolves. + useEffect(() => { + if (user) setLanguage(user.language); + }, [user]); + if (loading) { return
loading…
; } diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 6bff432..5d6a3d9 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -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 >
- Ticket {identity} + {t("pay.ticket")} {identity} - + ✕
- {session.isLoading &&
looking up…
} + {session.isLoading &&
{t("pay.lookingUp")}
} {s && !s.found && (
- No session found for this ticket. + {t("pay.noSessionFound")}
)} {s && s.found && !s.open && (
- This session is already closed (exited {formatTime(s.exitedAt)}). + {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
)} @@ -107,25 +109,28 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose <> {/* Session figures */}
- - - + + +
{/* Total */}
- Total + {t("pay.total")} {s.amountMinor != null && s.currency ? formatMoney(s.amountMinor, s.currency) : alreadyPaid - ? "paid" - : "no tariff"} + ? t("booth.badgePaid") + : t("pay.noTariff")}
@@ -137,19 +142,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {/* Tender */} {canPay && (
- Tender - {(["cash", "card"] as const).map((t) => ( + {t("pay.tender")} + {(["cash", "card"] as const).map((tn) => ( ))}
@@ -162,8 +167,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose checked={voucher} onChange={(e) => setPrintVoucherChecked(e.target.checked)} /> - Printo biletë dalje - (customer self-exits at the exit) + {t("pay.printExitVoucher")} + {t("pay.selfExitHint")} )} @@ -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")} ) : ( <> @@ -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")} )} diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index b2f7e86..ae6cf60 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; import { qk } from "./lib/query.js"; @@ -13,17 +14,18 @@ import { ActiveSessions } from "./ActiveSessions.js"; // the authoritative numbers; the WS-fed live store overlays real-time updates so // the screen reacts the instant a car enters or exits. Dense, dark, glanceable. -/** Per-event-type display: label + accent colour for the ticker. */ -const EVENT_STYLE: Record = { - vehicle_entry: { label: "ENTRY", color: "text-term-green" }, - vehicle_exit: { label: "EXIT", color: "text-term-red" }, - payment: { label: "PAY", color: "text-term-cyan" }, - void: { label: "VOID", color: "text-term-amber" }, - barrier_open_command: { label: "OPEN→", color: "text-term-muted" }, - barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" }, - shift_open: { label: "SHIFT+", color: "text-term-amber" }, - shift_z_report: { label: "SHIFT Z", color: "text-term-amber" }, - anomaly: { label: "ANOMALY", color: "text-term-red" }, +/** Per-event-type display: i18n label key + accent colour for the ticker. */ +const EVENT_STYLE: Record = { + vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" }, + vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" }, + payment: { labelKey: "booth.evtPay", color: "text-term-cyan" }, + void: { labelKey: "booth.evtVoid", color: "text-term-amber" }, + barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" }, + barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" }, + shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" }, + shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" }, + cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" }, + anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, }; function hhmmss(iso: string): string { @@ -33,6 +35,7 @@ function hhmmss(iso: string): string { } function OccupancyGauge({ occ }: { occ: Occupancy }) { + const { t } = useTranslation(); const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null; const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green"; return ( @@ -40,13 +43,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
{occ.count}
-
inside
+
{t("booth.inside")}
- {occ.capacity == null ? "uncapped" : `of ${occ.capacity}`} + {occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
-
free
+
{t("booth.free")}
{occ.free == null ? "∞" : occ.free}
@@ -59,7 +62,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) { )} {occ.full && (
- ● lot full + {t("booth.lotFull")}
)}
@@ -67,11 +70,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) { } function EventRow({ e }: { e: LedgerEvent }) { - const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" }; + const { t } = useTranslation(); + const style = EVENT_STYLE[e.type]; + const label = style ? t(style.labelKey) : e.type.toUpperCase(); return (
{hhmmss(e.occurredAt)} - {style.label} + {label} {e.identity ?? "—"} #{e.index}
@@ -82,6 +87,7 @@ function EventRow({ e }: { e: LedgerEvent }) { * operator types it. Either way, submit opens the pay/exit modal for that id. The * input auto-focuses and re-focuses after a scan so the scanner always lands here. */ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { + const { t } = useTranslation(); const [value, setValue] = useState(""); const ref = useRef(null); return ( @@ -102,7 +108,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { autoFocus value={value} onChange={(e) => setValue(e.target.value)} - placeholder="Scan or type ticket number…" + placeholder={t("booth.scanPlaceholder")} inputMode="numeric" className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber" /> @@ -110,13 +116,14 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { type="submit" className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber" > - Open + {t("booth.open")} ); } export function BoothScreen() { + const { t } = useTranslation(); // Initial load via Query (also the fallback if the WS is briefly down). const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) }); @@ -140,18 +147,18 @@ export function BoothScreen() {
{/* Ticket input spans both columns at the top — the operator's primary action. */}
- +
{/* Left column: occupancy gauge above the active-sessions list. */}
- }> + }> {occ ? ( ) : ( -
{occQuery.isError ? "occupancy unavailable" : "loading…"}
+
{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}
)}
@@ -160,15 +167,17 @@ export function BoothScreen() {
{events.length} events} + title={t("booth.liveFeed")} + right={ + + {events.length} {t("booth.events")} + + } className="min-h-0" >
{events.length === 0 ? ( -
- {eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."} -
+
{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}
) : ( events.map((e) => ) )} diff --git a/apps/web/src/Login.tsx b/apps/web/src/Login.tsx index 21463af..387309f 100644 --- a/apps/web/src/Login.tsx +++ b/apps/web/src/Login.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { login, type SessionUser } from "./api.js"; export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) { + const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) return (
-

Parking System

+

{t("auth.title")}

{error &&

{error}

}
diff --git a/apps/web/src/PermitManager.tsx b/apps/web/src/PermitManager.tsx index ba4f58f..4f201b3 100644 --- a/apps/web/src/PermitManager.tsx +++ b/apps/web/src/PermitManager.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ApiError, createPermit, @@ -41,6 +42,12 @@ function formFrom(p: Permit): FormState { platesText: p.plates.join(", "), }; } +const STATUS_KEY: Record = { + active: "permits.statusActive", + suspended: "permits.statusSuspended", + revoked: "permits.statusRevoked", +}; + function toInput(f: FormState): PermitInput { return { holderName: f.holderName.trim() || null, @@ -54,6 +61,7 @@ function toInput(f: FormState): PermitInput { } export function PermitManager() { + const { t } = useTranslation(); const [permits, setPermits] = useState(null); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); @@ -84,19 +92,19 @@ export function PermitManager() { else if (editing) await updatePermit(editing, toInput(form)); setEditing(null); reload(); - setMsg({ kind: "ok", text: "Permit saved." }); + setMsg({ kind: "ok", text: t("permits.permitSaved") }); } catch (e) { const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined; setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message }); } } async function doRevoke(p: Permit) { - if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return; + if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return; await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } async function doDelete(p: Permit) { - if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return; + if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return; await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message })); reload(); } @@ -109,71 +117,71 @@ export function PermitManager() { return (
-

Permits

+

{t("permits.title")}

    {permits.map((p) => (
  • - {p.holderName ?? "(unnamed)"} - {p.status} + {p.holderName ?? t("permits.unnamed")} + {t(STATUS_KEY[p.status])} - {p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "} - {p.credentials.length} cred · {p.plates.length} plate(s) + {p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "} + {p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })} - - {p.status !== "revoked" && } - + + {p.status !== "revoked" && } +
  • ))} - {permits.length === 0 &&
  • No permits yet.
  • } + {permits.length === 0 &&
  • {t("permits.noPermitsYet")}
  • }
{editing == null ? ( - + ) : (
-

{editing === "new" ? "New permit" : "Edit permit"}

+

{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}

- + setForm((f) => ({ ...f, holderName: e.target.value }))} /> - + setForm((f) => ({ ...f, contact: e.target.value }))} /> - + {form.carBound && ( setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> )} - - setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" /> - - setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" /> - - setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" /> + + setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} /> + + setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} /> + + setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
-

Credentials (card / QR)

+

{t("permits.credentialsCardQr")}

{form.credentials.map((c, i) => (
- setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} /> + setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
))} - +

- A permit needs at least one credential OR one bound plate. + {t("permits.needCredentialOrPlate")}

- - + +
)} diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index 804bea3..e213e48 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -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(null); const [drawerMinor, setDrawerMinor] = useState(null); const [currency, setCurrency] = useState(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 (
- Shift:{" "} + {t("shift.label")}{" "} {startedAt ? ( <> - open since {new Date(startedAt).toLocaleString()}{" "} + {t("shift.open")} {t("shift.since")}{" "} + {new Date(startedAt).toLocaleString()}{" "} ) : ( <> - not started{" "} + {t("shift.notStarted")}{" "} )} {/* Live drawer balance (what's in the till right now / inherited). */} {drawerMinor != null && (
- Drawer: {money(drawerMinor, currency)} - {startedAt && (opening float inherited from the prior shift)} + {t("shift.drawer")} {money(drawerMinor, currency)} + {startedAt && {t("shift.openingFloatInherited")}}
)} @@ -114,24 +117,24 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) { {isAdmin && (
- Drawer cash (admin) — load or remove the float + {t("shift.drawerCashAdmin")}
setMoveAmount(e.target.value)} - placeholder="amount" + placeholder={t("shift.amount")} inputMode="decimal" style={{ width: 90 }} /> setMoveReason(e.target.value)} - placeholder="reason (e.g. opening float)" + placeholder={t("shift.reasonPlaceholder")} style={{ flex: 1, minWidth: 140 }} /> - - + +
{moveMsg &&
{moveMsg}
}
@@ -139,20 +142,20 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) { {report && (
-
Z-REPORT — {report.operator}
-
Payments: {report.paymentCount}
-
Cash: {money(report.cashTotalMinor, report.currency)}
-
Card: {money(report.cardTotalMinor, report.currency)}
-
— Drawer —
-
Opening float: {money(report.openingFloatMinor, report.currency)}
-
Cash taken: {money(report.cashTotalMinor, report.currency)}
-
Cash added: {money(report.cashAddedMinor, report.currency)}
-
Cash removed: {money(report.cashRemovedMinor, report.currency)}
+
{t("shift.zReport")} — {report.operator}
+
{t("shift.payments")} {report.paymentCount}
+
{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}
+
{t("shift.card")} {money(report.cardTotalMinor, report.currency)}
+
{t("shift.drawerSection")}
+
{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}
+
{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}
+
{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}
+
{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}
- Expected drawer: {money(report.expectedDrawerMinor, report.currency)} + {t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
- {report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."} + {report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
)} diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index 417274e..27617b4 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -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(null); const [capInput, setCapInput] = useState(""); const [meta, setMeta] = useState>({}); @@ -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 (
- Occupancy:{" "} + {t("site.occupancy")}{" "} {occ == null ? ( "…" ) : ( <> {occ.count} - {occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"} + {occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`} {occ.capacity != null && ( - · {occ.free} free + · {occ.free} {t("site.free")} )} - {occ.full && FULL}{" "} + {occ.full && {t("site.full")}}{" "} )} {canEdit && (
- Park details (optional — shown on tickets/receipts) + {t("site.parkDetails")}
- {META_FIELDS.map(({ key, label, placeholder, multiline }) => ( + {META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (