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:
@@ -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…",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { sq } from "./sq.js";
|
||||
import { en } from "./en.js";
|
||||
|
||||
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
|
||||
// English (en) is the second language. The active language is the LOGGED-IN USER's
|
||||
// stored preference (users.language), applied via setLanguage() after auth resolves
|
||||
// — not localStorage, not the browser. Printed tickets are NOT governed by this
|
||||
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
|
||||
|
||||
export type Lang = "sq" | "en";
|
||||
|
||||
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
|
||||
// objects in the catalogs are walked by i18next's keySeparator.
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
sq: { translation: sq },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: "sq",
|
||||
fallbackLng: "sq",
|
||||
interpolation: { escapeValue: false }, // React already escapes
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
/** Apply a language (e.g. after login resolves the user's preference). No-op if
|
||||
* already active. */
|
||||
export function setLanguage(lang: Lang): void {
|
||||
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,227 @@
|
||||
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
|
||||
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
|
||||
// add it here first (the fallback), then mirror the key in en.ts.
|
||||
// See wiki/concepts/i18n.md.
|
||||
|
||||
export const sq = {
|
||||
common: {
|
||||
loading: "Duke u ngarkuar…",
|
||||
logout: "Dil",
|
||||
cancel: "Anulo",
|
||||
close: "Mbyll",
|
||||
save: "Ruaj",
|
||||
none: "—",
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
signIn: "Hyr",
|
||||
signingIn: "Duke hyrë…",
|
||||
},
|
||||
nav: {
|
||||
booth: "Kabina",
|
||||
shift: "Turni",
|
||||
setup: "Konfigurimi",
|
||||
tariff: "Tarifa",
|
||||
permits: "Lejet",
|
||||
site: "Vendi",
|
||||
},
|
||||
status: {
|
||||
live: "DREJTPËRDREJT",
|
||||
connecting: "DUKE U LIDHUR",
|
||||
offline: "JASHTË LINJE",
|
||||
},
|
||||
booth: {
|
||||
processTicket: "Proceso biletën",
|
||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||
open: "Hap",
|
||||
occupancy: "Zënia",
|
||||
occUnavailable: "zënia e padisponueshme",
|
||||
inside: "brenda",
|
||||
of: "nga",
|
||||
uncapped: "pa kufi",
|
||||
free: "lirë",
|
||||
lotFull: "● parkimi plot",
|
||||
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||
events: "ngjarje",
|
||||
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
inAt: "në",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
badgeUnpaid: "papaguar",
|
||||
// event types (live feed labels)
|
||||
evtEntry: "HYRJE",
|
||||
evtExit: "DALJE",
|
||||
evtPay: "PAGESË",
|
||||
evtVoid: "ANULIM",
|
||||
evtOpenCmd: "HAP→",
|
||||
evtOpenObserved: "HAP✓",
|
||||
evtShiftOpen: "TURN+",
|
||||
evtShiftZ: "TURN Z",
|
||||
evtCashMovement: "ARKË",
|
||||
evtAnomaly: "ANOMALI",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||
currency: "Monedha",
|
||||
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||
billingIncrement: "Intervali i faturimit (min)",
|
||||
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||
dailyCapPh: "p.sh. 12.00",
|
||||
lostTicketFee: "Tarifa për biletë të humbur",
|
||||
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||
rateBlocks: "Blloqet tarifore",
|
||||
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||
upToMin: "Deri në (min)",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
thereafter: "më pas",
|
||||
egExample: "p.sh. 60",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||
},
|
||||
permits: {
|
||||
title: "Lejet",
|
||||
unnamed: "(pa emër)",
|
||||
unbound: "pa kufizim",
|
||||
car_one: "{{count}} makinë",
|
||||
car_other: "{{count}} makina",
|
||||
cred: "kredencial",
|
||||
plates: "{{count}} targë(a)",
|
||||
edit: "Ndrysho",
|
||||
revoke: "Anulo",
|
||||
delete: "Fshij",
|
||||
noPermitsYet: "Asnjë leje ende.",
|
||||
addPermit: "+ Shto leje",
|
||||
newPermit: "Leje e re",
|
||||
editPermit: "Ndrysho lejen",
|
||||
holderName: "Emri i mbajtësit",
|
||||
contact: "Kontakti",
|
||||
carLimit: "Kufiri i makinave",
|
||||
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||
validFrom: "Vlen nga",
|
||||
validTo: "Vlen deri",
|
||||
isoDateOptional: "Datë ISO (opsionale)",
|
||||
boundPlates: "Targat e lidhura",
|
||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||
rfCardTag: "Kartë/etiketë RF",
|
||||
qr: "QR",
|
||||
credentialValue: "vlera e kredencialit",
|
||||
addCredential: "+ kredencial",
|
||||
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||
save: "Ruaj",
|
||||
cancel: "Anulo",
|
||||
permitSaved: "Leja u ruajt.",
|
||||
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.",
|
||||
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||
statusActive: "aktive",
|
||||
statusSuspended: "pezulluar",
|
||||
statusRevoked: "anuluar",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Zënia:",
|
||||
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||
free: "lirë",
|
||||
full: "PLOT",
|
||||
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||
capacityPlaceholder: "p.sh. 120",
|
||||
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
fieldParkName: "Emri i parkimit",
|
||||
fieldParkNamePh: "p.sh. Acme Parking",
|
||||
fieldOperator: "Operatori (emri ligjor)",
|
||||
fieldOperatorPh: "kompania operuese",
|
||||
fieldNius: "NIUS",
|
||||
fieldNiusPh: "p.sh. L01234567A",
|
||||
fieldAddress: "Adresa",
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
shift: {
|
||||
label: "Turni:",
|
||||
open: "hapur",
|
||||
notStarted: "i panisur",
|
||||
since: "që nga",
|
||||
startShift: "Fillo turnin",
|
||||
starting: "Duke filluar…",
|
||||
endShift: "Mbyll turnin",
|
||||
ending: "Duke mbyllur…",
|
||||
drawer: "Arka:",
|
||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||
load: "Shto +",
|
||||
remove: "Hiq −",
|
||||
enterPositive: "Shkruaj një shumë pozitive.",
|
||||
drawerNow: "Arka tani {{amount}}.",
|
||||
zReport: "RAPORT Z",
|
||||
payments: "Pagesa:",
|
||||
cash: "Para:",
|
||||
card: "Kartë:",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Bilanci fillestar:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
expectedDrawer: "Arka e pritshme:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
now: "Tani",
|
||||
duration: "Kohëzgjatja",
|
||||
statusLabel: "Statusi",
|
||||
paid: "PAGUAR",
|
||||
unpaid: "PAPAGUAR",
|
||||
total: "Totali",
|
||||
noTariff: "pa tarifë",
|
||||
tender: "Mënyra",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
printExitVoucher: "Printo biletë dalje",
|
||||
selfExitHint: "(klienti del vetë te dalja)",
|
||||
payAndOpen: "Paguaj + hap barrierën",
|
||||
payAndVoucher: "Paguaj + printo biletën",
|
||||
openBarrier: "Hap barrierën",
|
||||
printVoucher: "Printo biletën",
|
||||
takingPayment: "Duke marrë pagesën…",
|
||||
printingVoucher: "Duke printuar biletën…",
|
||||
opening: "Duke hapur…",
|
||||
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
|
||||
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// snapshots
|
||||
noSnapshots: "asnjë foto",
|
||||
loadingSnapshots: "duke ngarkuar fotot…",
|
||||
},
|
||||
};
|
||||
|
||||
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
|
||||
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
|
||||
// would pin en.ts to the Albanian literals.)
|
||||
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
|
||||
export type Catalog = Stringify<typeof sq>;
|
||||
Reference in New Issue
Block a user