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
+218
View File
@@ -0,0 +1,218 @@
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
import type { Catalog } from "./sq.js";
export const en: Catalog = {
common: {
loading: "Loading…",
logout: "Log out",
cancel: "Cancel",
close: "Close",
save: "Save",
none: "—",
},
auth: {
title: "Parking System",
username: "Username",
password: "Password",
signIn: "Sign in",
signingIn: "Signing in…",
},
nav: {
booth: "Booth",
shift: "Shift",
setup: "Setup",
tariff: "Tariff",
permits: "Permits",
site: "Site",
},
status: {
live: "LIVE",
connecting: "CONNECTING",
offline: "OFFLINE",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
open: "Open",
occupancy: "Occupancy",
occUnavailable: "occupancy unavailable",
inside: "inside",
of: "of",
uncapped: "uncapped",
free: "free",
lotFull: "● lot full",
liveFeed: "Live feed",
events: "events",
noEventsYet: "No events yet — entries and exits will stream here.",
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
inAt: "in",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
barrierOpened: "barrier opened",
openManually: "open manually",
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
evtVoid: "VOID",
evtOpenCmd: "OPEN→",
evtOpenObserved: "OPEN✓",
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtAnomaly: "ANOMALY",
},
tariff: {
title: "Tariff",
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
currency: "Currency",
freeEntryGrace: "Free entry grace (min)",
billingIncrement: "Billing increment (min)",
dailyCap: "Daily cap (blank = none)",
dailyCapPh: "e.g. 12.00",
lostTicketFee: "Lost-ticket fee",
exitGrace: "Exit walk-back grace (min)",
rateBlocks: "Rate blocks",
rateBlocksHint: "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.",
upToMin: "Up to (min)",
pricePerIncrement: "Price / increment",
thereafter: "thereafter",
egExample: "e.g. 60",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
},
permits: {
title: "Permits",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noPermitsYet: "No permits yet.",
addPermit: "+ Add permit",
newPermit: "New permit",
editPermit: "Edit permit",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
qr: "QR",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
permitSaved: "Permit saved.",
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
free: "free",
full: "FULL",
capacityLabel: "Capacity (blank = no limit):",
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
fieldParkName: "Park name",
fieldParkNamePh: "e.g. Acme Parking",
fieldOperator: "Operator (legal name)",
fieldOperatorPh: "operating company",
fieldNius: "NIUS",
fieldNiusPh: "e.g. L01234567A",
fieldAddress: "Address",
fieldPhone: "Phone",
fieldEmail: "Email",
},
shift: {
label: "Shift:",
open: "open",
notStarted: "not started",
since: "since",
startShift: "Start shift",
starting: "Starting…",
endShift: "End shift",
ending: "Ending…",
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
drawerSection: "— Drawer —",
openingFloat: "Opening float:",
cashTaken: "Cash taken:",
cashAdded: "Cash added:",
cashRemoved: "Cash removed:",
expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).",
},
pay: {
ticket: "Ticket",
entry: "Entry",
now: "Now",
duration: "Duration",
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
cash: "Cash",
card: "Card",
printExitVoucher: "Print exit ticket",
selfExitHint: "(customer self-exits at the exit)",
payAndOpen: "Pay + open barrier",
payAndVoucher: "Pay + print voucher",
openBarrier: "Open barrier",
printVoucher: "Print voucher",
takingPayment: "taking payment…",
printingVoucher: "printing voucher…",
opening: "opening…",
noSessionFound: "No session found for this ticket.",
alreadyClosed: "This session is already closed (exited {{time}}).",
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
},
};