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
+2
View File
@@ -17,8 +17,10 @@
"@radix-ui/react-tabs": "^1.1.15", "@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
"i18next": "^26.3.1",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
+21 -13
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.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). // No payment → no Open barrier button (the no-unpaid-bypass rule).
// See wiki/concepts/booth-exit-flow.md. // See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { label: string; cls: string } { function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" }; if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { label: "paid", cls: "text-term-green" }; if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { label: "unpaid", cls: "text-term-amber" }; return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
} }
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: qk.activeSessions, queryKey: qk.activeSessions,
@@ -48,7 +50,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
setReopenMsg({ setReopenMsg({
id: s.identity, id: s.identity,
ok: r.opened, 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) { } catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
@@ -57,13 +59,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return ( return (
<Panel <Panel
title="Active sessions" title={t("booth.activeSessions")}
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{sessions.length} inside</span>} right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
</span>
}
className="min-h-0" className="min-h-0"
> >
<div className="h-full overflow-y-auto pr-1"> <div className="h-full overflow-y-auto pr-1">
{sessions.length === 0 ? ( {sessions.length === 0 ? (
<div className="text-term-muted">{isLoading ? "loading…" : "no active sessions."}</div> <div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
) : ( ) : (
sessions.map((s) => { sessions.map((s) => {
const badge = statusBadge(s); const badge = statusBadge(s);
@@ -77,12 +83,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
type="button" type="button"
onClick={() => onPick(s.identity)} onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber" className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title="Open pay / exit" title={t("booth.openPayExit")}
> >
<span className="text-term-text">{s.identity}</span> <span className="text-term-text">{s.identity}</span>
<span className="text-term-muted">in {formatTime(s.enteredAt)}</span> <span className="text-term-muted">
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span> <span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{badge.label}</span> <span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button> </button>
{/* Open barrier — PAID sessions only (no payment, no button). */} {/* Open barrier — PAID sessions only (no payment, no button). */}
@@ -92,9 +100,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
disabled={reopen.isPending} disabled={reopen.isPending}
onClick={() => handleReopen(s)} 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" 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")}
</button> </button>
) : ( ) : (
<span className="w-[88px] shrink-0" /> <span className="w-[88px] shrink-0" />
+7
View File
@@ -4,6 +4,7 @@ import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js"; import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { router } from "./router.js"; import { router } from "./router.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
@@ -22,6 +23,12 @@ export function App() {
.finally(() => setLoading(false)); .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) { if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>; return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
} }
+38 -33
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -22,6 +23,7 @@ import { SnapshotStrip } from "./ui/SnapshotStrip.js";
type Phase = "review" | "paying" | "finishing" | "done" | "error"; type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) { export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) }); const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig }); const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
@@ -52,13 +54,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
setPhase("finishing"); setPhase("finishing");
if (voucher) { if (voucher) {
const r = await printVoucher(identity); 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 { } else {
const r = await boothExit(identity); const r = await boothExit(identity);
setResult( setResult(
r.opened r.opened
? "Paid — barrier opened. Car may exit." ? t("pay.paidBarrierOpened")
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`, : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
); );
} }
// Refresh the live views. // Refresh the live views.
@@ -81,25 +83,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
> >
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2"> <div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber"> <Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
Ticket {identity} {t("pay.ticket")} {identity}
</Dialog.Title> </Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close"> <Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕ ✕
</Dialog.Close> </Dialog.Close>
</div> </div>
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
{session.isLoading && <div className="text-term-muted">looking up…</div>} {session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && ( {s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red"> <div className="rounded-term border border-term-red px-3 py-2 text-term-red">
No session found for this ticket. {t("pay.noSessionFound")}
</div> </div>
)} )}
{s && s.found && !s.open && ( {s && s.found && !s.open && (
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber"> <div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
This session is already closed (exited {formatTime(s.exitedAt)}). {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
</div> </div>
)} )}
@@ -107,25 +109,28 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<> <>
{/* Session figures */} {/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label="Entry" value={formatTime(s.enteredAt)} /> <Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
<Row label="Now" value={formatTime(new Date().toISOString())} /> <Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
<Row <Row
label="Status" label={t("pay.duration")}
value={alreadyPaid ? "PAID" : "UNPAID"} value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
/>
<Row
label={t("pay.statusLabel")}
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"} valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
/> />
</div> </div>
{/* Total */} {/* Total */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2"> <div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Total</span> <span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
<span className="text-3xl font-bold text-term-cyan"> <span className="text-3xl font-bold text-term-cyan">
{s.amountMinor != null && s.currency {s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency) ? formatMoney(s.amountMinor, s.currency)
: alreadyPaid : alreadyPaid
? "paid" ? t("booth.badgePaid")
: "no tariff"} : t("pay.noTariff")}
</span> </span>
</div> </div>
@@ -137,19 +142,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Tender */} {/* Tender */}
{canPay && ( {canPay && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span> <span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((t) => ( {(["cash", "card"] as const).map((tn) => (
<button <button
key={t} key={tn}
type="button" type="button"
onClick={() => setTender(t)} onClick={() => setTender(tn)}
className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${ className={`rounded-term border px-3 py-1 text-[12px] uppercase tracking-wider ${
tender === t tender === tn
? "border-term-amber text-term-amber" ? "border-term-amber text-term-amber"
: "border-term-border text-term-muted hover:text-term-text" : "border-term-border text-term-muted hover:text-term-text"
}`} }`}
> >
{t} {t(`pay.${tn}`)}
</button> </button>
))} ))}
</div> </div>
@@ -162,8 +167,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
checked={voucher} checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)} onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/> />
Printo biletë dalje {t("pay.printExitVoucher")}
<span className="text-term-muted">(customer self-exits at the exit)</span> <span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label> </label>
</> </>
)} )}
@@ -181,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={onClose} onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber" className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
> >
Close {t("common.close")}
</button> </button>
) : ( ) : (
<> <>
@@ -190,7 +195,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={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" 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")}
</button> </button>
<button <button
type="button" type="button"
@@ -199,18 +204,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50" className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
> >
{phase === "paying" {phase === "paying"
? "taking payment…" ? t("pay.takingPayment")
: phase === "finishing" : phase === "finishing"
? voucher ? voucher
? "printing voucher…" ? t("pay.printingVoucher")
: "opening…" : t("pay.opening")
: alreadyPaid : alreadyPaid
? voucher ? voucher
? "Print voucher" ? t("pay.printVoucher")
: "Open barrier" : t("pay.openBarrier")
: voucher : voucher
? "Pay + print voucher" ? t("pay.payAndVoucher")
: "Pay + open barrier"} : t("pay.payAndOpen")}
</button> </button>
</> </>
)} )}
+36 -27
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { qk } from "./lib/query.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 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. // the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
/** Per-event-type display: label + accent colour for the ticker. */ /** Per-event-type display: i18n label key + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { label: string; color: string }> = { const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { label: "ENTRY", color: "text-term-green" }, vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { label: "EXIT", color: "text-term-red" }, vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { label: "PAY", color: "text-term-cyan" }, payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { label: "VOID", color: "text-term-amber" }, void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { label: "OPEN→", color: "text-term-muted" }, barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" }, barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { label: "SHIFT+", color: "text-term-amber" }, shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" }, shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
anomaly: { label: "ANOMALY", color: "text-term-red" }, cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
}; };
function hhmmss(iso: string): string { function hhmmss(iso: string): string {
@@ -33,6 +35,7 @@ function hhmmss(iso: string): string {
} }
function OccupancyGauge({ occ }: { occ: Occupancy }) { function OccupancyGauge({ occ }: { occ: Occupancy }) {
const { t } = useTranslation();
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null; 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"; const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
return ( return (
@@ -40,13 +43,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
<div className="flex items-end gap-4"> <div className="flex items-end gap-4">
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div> <div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
<div className="pb-1 text-term-muted"> <div className="pb-1 text-term-muted">
<div className="text-[11px] uppercase tracking-wider">inside</div> <div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
<div className="text-sm tabular-nums"> <div className="text-sm tabular-nums">
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`} {occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div> </div>
</div> </div>
<div className="ml-auto text-right"> <div className="ml-auto text-right">
<div className="text-[11px] uppercase tracking-wider text-term-muted">free</div> <div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}> <div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
{occ.free == null ? "∞" : occ.free} {occ.free == null ? "∞" : occ.free}
</div> </div>
@@ -59,7 +62,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
)} )}
{occ.full && ( {occ.full && (
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red"> <div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
● lot full {t("booth.lotFull")}
</div> </div>
)} )}
</div> </div>
@@ -67,11 +70,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
} }
function EventRow({ e }: { e: LedgerEvent }) { 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 ( return (
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums"> <div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span> <span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`w-20 shrink-0 font-semibold ${style.color}`}>{style.label}</span> <span className={`w-20 shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className="truncate text-term-text">{e.identity ?? "—"}</span> <span className="truncate text-term-text">{e.identity ?? "—"}</span>
<span className="ml-auto text-term-muted">#{e.index}</span> <span className="ml-auto text-term-muted">#{e.index}</span>
</div> </div>
@@ -82,6 +87,7 @@ function EventRow({ e }: { e: LedgerEvent }) {
* operator types it. Either way, submit opens the pay/exit modal for that id. The * 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. */ * input auto-focuses and re-focuses after a scan so the scanner always lands here. */
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
return ( return (
@@ -102,7 +108,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
autoFocus autoFocus
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="Scan or type ticket number…" placeholder={t("booth.scanPlaceholder")}
inputMode="numeric" 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" 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" 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" 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")}
</button> </button>
</form> </form>
); );
} }
export function BoothScreen() { export function BoothScreen() {
const { t } = useTranslation();
// Initial load via Query (also the fallback if the WS is briefly down). // Initial load via Query (also the fallback if the WS is briefly down).
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) }); const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
@@ -140,18 +147,18 @@ export function BoothScreen() {
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]"> <div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */} {/* Ticket input spans both columns at the top — the operator's primary action. */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Panel title="Process ticket"> <Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} /> <TicketInput onSubmit={setActiveTicket} />
</Panel> </Panel>
</div> </div>
{/* Left column: occupancy gauge above the active-sessions list. */} {/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3"> <div className="flex min-h-0 flex-col gap-3">
<Panel title="Occupancy" right={<StatusDot />}> <Panel title={t("booth.occupancy")} right={<StatusDot />}>
{occ ? ( {occ ? (
<OccupancyGauge occ={occ} /> <OccupancyGauge occ={occ} />
) : ( ) : (
<div className="text-term-muted">{occQuery.isError ? "occupancy unavailable" : "loading…"}</div> <div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)} )}
</Panel> </Panel>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
@@ -160,15 +167,17 @@ export function BoothScreen() {
</div> </div>
<Panel <Panel
title="Live feed" title={t("booth.liveFeed")}
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>} right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
</span>
}
className="min-h-0" className="min-h-0"
> >
<div className="h-full overflow-y-auto pr-1"> <div className="h-full overflow-y-auto pr-1">
{events.length === 0 ? ( {events.length === 0 ? (
<div className="text-term-muted"> <div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
</div>
) : ( ) : (
events.map((e) => <EventRow key={e.id} e={e} />) events.map((e) => <EventRow key={e.id} e={e} />)
)} )}
+6 -4
View File
@@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { login, type SessionUser } from "./api.js"; import { login, type SessionUser } from "./api.js";
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) { export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
return ( return (
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}> <main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
<h1>Parking System</h1> <h1>{t("auth.title")}</h1>
<form onSubmit={submit}> <form onSubmit={submit}>
<div style={{ margin: "0.5rem 0" }}> <div style={{ margin: "0.5rem 0" }}>
<label> <label>
Username {t("auth.username")}
<br /> <br />
<input <input
value={username} value={username}
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div> </div>
<div style={{ margin: "0.5rem 0" }}> <div style={{ margin: "0.5rem 0" }}>
<label> <label>
Password {t("auth.password")}
<br /> <br />
<input <input
type="password" type="password"
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div> </div>
{error && <p style={{ color: "crimson" }}>{error}</p>} {error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit" disabled={busy || !username || !password}> <button type="submit" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"} {busy ? t("auth.signingIn") : t("auth.signIn")}
</button> </button>
</form> </form>
</main> </main>
+40 -32
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApiError, ApiError,
createPermit, createPermit,
@@ -41,6 +42,12 @@ function formFrom(p: Permit): FormState {
platesText: p.plates.join(", "), platesText: p.plates.join(", "),
}; };
} }
const STATUS_KEY: Record<Permit["status"], string> = {
active: "permits.statusActive",
suspended: "permits.statusSuspended",
revoked: "permits.statusRevoked",
};
function toInput(f: FormState): PermitInput { function toInput(f: FormState): PermitInput {
return { return {
holderName: f.holderName.trim() || null, holderName: f.holderName.trim() || null,
@@ -54,6 +61,7 @@ function toInput(f: FormState): PermitInput {
} }
export function PermitManager() { export function PermitManager() {
const { t } = useTranslation();
const [permits, setPermits] = useState<Permit[] | null>(null); const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null); const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
@@ -84,19 +92,19 @@ export function PermitManager() {
else if (editing) await updatePermit(editing, toInput(form)); else if (editing) await updatePermit(editing, toInput(form));
setEditing(null); setEditing(null);
reload(); reload();
setMsg({ kind: "ok", text: "Permit saved." }); setMsg({ kind: "ok", text: t("permits.permitSaved") });
} catch (e) { } catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined; 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 }); setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
} }
} }
async function doRevoke(p: Permit) { 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 })); await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload(); reload();
} }
async function doDelete(p: Permit) { 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 })); await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload(); reload();
} }
@@ -109,71 +117,71 @@ export function PermitManager() {
return ( return (
<section style={{ marginTop: "2rem" }}> <section style={{ marginTop: "2rem" }}>
<h2>Permits</h2> <h2>{t("permits.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}> <ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => ( {permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}> <li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? "(unnamed)"}</strong> <strong>{p.holderName ?? t("permits.unnamed")}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span> <span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
<span style={{ color: "#666" }}> <span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "} {p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s) {p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
</span> </span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button> <button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>} {p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button> <button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
</li> </li>
))} ))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>} {permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
</ul> </ul>
{editing == null ? ( {editing == null ? (
<button type="button" onClick={startNew}>+ Add permit</button> <button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
) : ( ) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}> <div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3> <h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}> <div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>Holder name</label> <label>{t("permits.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} /> <input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>Contact</label> <label>{t("permits.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} /> <input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>Car limit</label> <label>{t("permits.carLimit")}</label>
<span> <span>
<label style={{ marginRight: "0.5rem" }}> <label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once <input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
</label> </label>
{form.carBound && ( {form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> <input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)} )}
</span> </span>
<label>Valid from</label> <label>{t("permits.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" /> <input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>Valid to</label> <label>{t("permits.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" /> <input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>Bound plates</label> <label>{t("permits.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" /> <input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
</div> </div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4> <h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
{form.credentials.map((c, i) => ( {form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}> <div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}> <select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">RF card/tag</option> <option value="rf">{t("permits.rfCardTag")}</option>
<option value="qr">QR</option> <option value="qr">{t("permits.qr")}</option>
</select> </select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} /> <input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button> <button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div> </div>
))} ))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button> <button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("permits.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}> <p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
A permit needs at least one credential OR one bound plate. {t("permits.needCredentialOrPlate")}
</p> </p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}> <div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button> <button type="button" onClick={save}>{t("permits.save")}</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button> <button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
</div> </div>
</div> </div>
)} )}
+28 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js"; import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see // 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(); const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) { export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null); const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null); const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null); const [currency, setCurrency] = useState<string | null>(null);
@@ -68,14 +70,14 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
setMoveMsg(null); setMoveMsg(null);
const major = Number(moveAmount); const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) { if (!Number.isFinite(major) || major <= 0) {
setMoveMsg("Enter a positive amount."); setMoveMsg(t("shift.enterPositive"));
return; return;
} }
try { try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim()); const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
setMoveAmount(""); setMoveAmount("");
setMoveReason(""); setMoveReason("");
setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`); setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
refresh(); refresh();
} catch (e) { } catch (e) {
setMoveMsg((e as Error).message); setMoveMsg((e as Error).message);
@@ -84,27 +86,28 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Shift:</strong>{" "} <strong>{t("shift.label")}</strong>{" "}
{startedAt ? ( {startedAt ? (
<> <>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "} <span style={{ color: "#16a34a" }}>{t("shift.open")}</span> {t("shift.since")}{" "}
{new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}> <button type="button" onClick={end} disabled={busy}>
{busy ? "Ending…" : "End shift"} {busy ? t("shift.ending") : t("shift.endShift")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<span style={{ color: "#777" }}>not started</span>{" "} <span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
<button type="button" onClick={start} disabled={busy}> <button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"} {busy ? t("shift.starting") : t("shift.startShift")}
</button> </button>
</> </>
)} )}
{/* Live drawer balance (what's in the till right now / inherited). */} {/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && ( {drawerMinor != null && (
<div style={{ marginTop: "0.5rem", color: "#555" }}> <div style={{ marginTop: "0.5rem", color: "#555" }}>
Drawer: <strong>{money(drawerMinor, currency)}</strong> {t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>} {startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
</div> </div>
)} )}
@@ -114,24 +117,24 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{isAdmin && ( {isAdmin && (
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}> <div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}> <div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
Drawer cash (admin) — load or remove the float {t("shift.drawerCashAdmin")}
</div> </div>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input <input
value={moveAmount} value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)} onChange={(e) => setMoveAmount(e.target.value)}
placeholder="amount" placeholder={t("shift.amount")}
inputMode="decimal" inputMode="decimal"
style={{ width: 90 }} style={{ width: 90 }}
/> />
<input <input
value={moveReason} value={moveReason}
onChange={(e) => setMoveReason(e.target.value)} onChange={(e) => setMoveReason(e.target.value)}
placeholder="reason (e.g. opening float)" placeholder={t("shift.reasonPlaceholder")}
style={{ flex: 1, minWidth: 140 }} style={{ flex: 1, minWidth: 140 }}
/> />
<button type="button" onClick={() => move(1)}>Load +</button> <button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" onClick={() => move(-1)}>Remove −</button> <button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div> </div>
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>} {moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
</div> </div>
@@ -139,20 +142,20 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{report && ( {report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}> <div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div> <div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
<div>Payments: {report.paymentCount}</div> <div>{t("shift.payments")} {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div> <div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div> <div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div> <div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div> <div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div> <div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div> <div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div> <div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}> <div style={{ fontWeight: 600 }}>
Expected drawer: {money(report.expectedDrawerMinor, report.currency)} {t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div> </div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}> <div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."} {report.printed ? t("shift.printedToReceipt") : t("shift.recordedNoPrinter")}
</div> </div>
</div> </div>
)} )}
+26 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js"; import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a // 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. // entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md. // See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order, with labels + placeholders. // The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [ // (resolved at render); only `address` is multiline.
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" }, const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" }, { key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" }, { key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
{ key: "address", label: "Address", multiline: true }, { key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
{ key: "phone", label: "Phone" }, { key: "address", labelKey: "site.fieldAddress", multiline: true },
{ key: "email", label: "Email" }, { key: "phone", labelKey: "site.fieldPhone" },
{ key: "email", labelKey: "site.fieldEmail" },
]; ];
export function SiteSettings({ canEdit }: { canEdit: boolean }) { export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [occ, setOcc] = useState<Occupancy | null>(null); const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState(""); const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({}); const [meta, setMeta] = useState<Record<string, string>>({});
@@ -52,7 +55,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
try { try {
await saveSiteConfig(patch); await saveSiteConfig(patch);
reload(); reload();
setMsg("Saved."); setMsg(t("site.saved"));
} catch (e) { } catch (e) {
setMsg((e as Error).message); setMsg((e as Error).message);
} }
@@ -60,25 +63,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return ( return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}> <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 ? ( {occ == null ? (
"…" "…"
) : ( ) : (
<> <>
<span style={{ fontWeight: 600 }}>{occ.count}</span> <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 && ( {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> <button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</> </>
)} )}
{canEdit && ( {canEdit && (
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}> <div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label> <label>
Capacity (blank = no limit):{" "} {t("site.capacityLabel")}{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" /> <input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}> <label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input <input
@@ -86,35 +89,33 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
checked={exitVoucherDefault} checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)} onChange={(e) => setExitVoucherDefault(e.target.checked)}
/> />
Print exit ticket by default {t("site.printExitDefault")}
<span style={{ color: "#888", fontSize: "0.8rem" }}> <span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
(booth far from exit → customer self-exits with a voucher)
</span>
</label> </label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}> <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> </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 key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label} {t(labelKey)}
{multiline ? ( {multiline ? (
<textarea <textarea
value={meta[key] ?? ""} value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))} onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2} rows={2}
placeholder={placeholder} placeholder={phKey ? t(phKey) : undefined}
/> />
) : ( ) : (
<input <input
value={meta[key] ?? ""} value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))} onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder} placeholder={phKey ? t(phKey) : undefined}
/> />
)} )}
</label> </label>
))} ))}
<div> <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>} {msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div> </div>
</div> </div>
+24 -26
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApiError, ApiError,
fetchTariff, fetchTariff,
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
} }
export function TariffComposer() { export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null); const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -112,7 +114,7 @@ export function TariffComposer() {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff(); const fresh = await fetchTariff();
setState(fresh); 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) { } catch (e) {
const text = const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
@@ -126,44 +128,40 @@ export function TariffComposer() {
return ( return (
<section style={{ marginTop: "2rem" }}> <section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2> <h2>{t("tariff.title")}</h2>
{!state?.active ? ( {!state?.active ? (
<p style={{ color: "#b45309" }}> <p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
No rate card published yet — the pay station can't charge until you publish one.
</p>
) : ( ) : (
<p style={{ color: "#555" }}> <p style={{ color: "#555" }}>
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "} {t("tariff.activeSince", {
{state.versions.length} version(s) in history. Publishing creates a new version; past date: new Date(state.active.effectiveFrom).toLocaleString(),
sessions keep their original pricing. count: state.versions.length,
})}
</p> </p>
)} )}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}> <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 }} /> <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)} /> <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)} /> <input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label> <label>{t("tariff.dailyCap")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" /> <input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
<label>Lost-ticket fee</label> <label>{t("tariff.lostTicketFee")}</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} /> <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)} /> <input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div> </div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3> <h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}> <p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
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>
<table style={{ borderCollapse: "collapse" }}> <table style={{ borderCollapse: "collapse" }}>
<thead> <thead>
<tr style={{ textAlign: "left", color: "#555" }}> <tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th> <th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th> <th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -174,7 +172,7 @@ export function TariffComposer() {
<input <input
value={b.uptoMin} value={b.uptoMin}
onChange={(e) => setBlock(i, { uptoMin: e.target.value })} 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 }} style={{ width: 110 }}
/> />
</td> </td>
@@ -183,7 +181,7 @@ export function TariffComposer() {
</td> </td>
<td> <td>
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}> <button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
Remove {t("tariff.remove")}
</button> </button>
</td> </td>
</tr> </tr>
@@ -191,12 +189,12 @@ export function TariffComposer() {
</tbody> </tbody>
</table> </table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}> <button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
+ Add block {t("tariff.addBlock")}
</button> </button>
<div style={{ marginTop: "1rem" }}> <div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}> <button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"} {saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button> </button>
</div> </div>
{msg && ( {msg && (
+8
View File
@@ -45,10 +45,13 @@ export class ApiError extends Error {
// --- Auth ----------------------------------------------------------------- // --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly"; export type Role = "admin" | "operator" | "cashier" | "readonly";
export type Lang = "sq" | "en";
export interface SessionUser { export interface SessionUser {
id: string; id: string;
username: string; username: string;
role: Role; role: Role;
/** Preferred UI language (loaded from the server on login). */
language: Lang;
} }
export function login(username: string, password: string): Promise<SessionUser> { export function login(username: string, password: string): Promise<SessionUser> {
@@ -62,6 +65,11 @@ export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" }); return apiFetch("/api/auth/logout", { method: "POST" });
} }
/** Persist the current user's UI language preference (restored on next login). */
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
}
/** Returns the current user, or null if not authenticated. */ /** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> { export async function fetchMe(): Promise<SessionUser | null> {
try { try {
+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…",
},
};
+33
View File
@@ -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;
+227
View File
@@ -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>;
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import "./lib/i18n/index.js"; // initialize i18next before the app renders
import { App } from "./App.js"; import { App } from "./App.js";
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
+50 -9
View File
@@ -6,9 +6,11 @@ import {
Outlet, Outlet,
redirect, redirect,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import type { SessionUser } from "./api.js"; import { useTranslation } from "react-i18next";
import { logout } from "./api.js"; import type { Lang, SessionUser } from "./api.js";
import { logout, setLanguagePref } from "./api.js";
import { queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js"; import { BoothScreen } from "./BoothScreen.js";
@@ -43,8 +45,46 @@ function NavLink({ to, label }: { to: string; label: string }) {
); );
} }
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
* and applies it immediately. Updates the router-context user so App re-syncs. */
function LanguageToggle({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
async function pick(lang: Lang) {
if (lang === user.language) return;
setLanguage(lang); // instant UI
setUser({ ...user, language: lang });
try {
await setLanguagePref(lang); // persist
} catch {
/* non-fatal — the choice still applies this session */
}
}
return (
<div className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider">
{(["sq", "en"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
</button>
))}
</div>
);
}
function RootLayout() { function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext(); const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget). // One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed(); useLiveFeed();
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
@@ -54,14 +94,15 @@ function RootLayout() {
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2"> <header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span> <span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
<NavLink to="/booth" label="Booth" /> <NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label="Shift" /> <NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label="Setup" />} {isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label="Tariff" />} {isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label="Permits" />} {isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
{isAdmin && <NavLink to="/site" label="Site" />} {isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{user && <LanguageToggle user={user} setUser={setUser} />}
<StatusDot /> <StatusDot />
<span className="text-[11px] text-term-muted"> <span className="text-[11px] text-term-muted">
{user?.username} · {user?.role} {user?.username} · {user?.role}
@@ -74,7 +115,7 @@ function RootLayout() {
setUser(null); setUser(null);
}} }}
> >
Log out {t("common.logout")}
</button> </button>
</div> </div>
</header> </header>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js"; import { fetchSnapshots, snapshotImageUrl } from "../api.js";
@@ -7,6 +8,7 @@ import { fetchSnapshots, snapshotImageUrl } from "../api.js";
// served with a long immutable cache); clicking one enlarges it. Read-only. // served with a long immutable cache); clicking one enlarges it. Read-only.
export function SnapshotStrip({ identity }: { identity: string }) { export function SnapshotStrip({ identity }: { identity: string }) {
const { t } = useTranslation();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity], queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity), queryFn: () => fetchSnapshots(identity),
@@ -16,8 +18,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const shots = data?.snapshots ?? []; const shots = data?.snapshots ?? [];
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>; if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>; if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return ( return (
<> <>
+7 -5
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useLiveStore, type WsStatus } from "../lib/live-store.js"; import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// Small live-connection indicator for the booth chrome: a coloured dot + label // Small live-connection indicator for the booth chrome: a coloured dot + label
@@ -8,20 +9,21 @@ const COLOR: Record<WsStatus, string> = {
connecting: "bg-term-amber", connecting: "bg-term-amber",
closed: "bg-term-red", closed: "bg-term-red",
}; };
const LABEL: Record<WsStatus, string> = { const LABEL_KEY: Record<WsStatus, string> = {
open: "LIVE", open: "status.live",
connecting: "CONNECTING", connecting: "status.connecting",
closed: "OFFLINE", closed: "status.offline",
}; };
export function StatusDot() { export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status); const status = useLiveStore((s) => s.status);
return ( return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted"> <span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span <span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`} className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/> />
{LABEL[status]} {t(LABEL_KEY[status])}
</span> </span>
); );
} }
+64
View File
@@ -87,12 +87,18 @@ importers:
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.170.16 specifier: ^1.170.16
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
i18next:
specifier: ^26.3.1
version: 26.3.1(typescript@6.0.3)
react: react:
specifier: 19.2.7 specifier: 19.2.7
version: 19.2.7 version: 19.2.7
react-dom: react-dom:
specifier: 19.2.7 specifier: 19.2.7
version: 19.2.7(react@19.2.7) version: 19.2.7(react@19.2.7)
react-i18next:
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
zustand: zustand:
specifier: ^5.0.14 specifier: ^5.0.14
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
@@ -165,6 +171,10 @@ importers:
packages: packages:
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@drizzle-team/brocli@0.10.2': '@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1630,10 +1640,21 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
http-errors@2.0.1: http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
i18next@26.3.1:
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1852,6 +1873,22 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.7 react: ^19.2.7
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-remove-scroll-bar@2.3.8: react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2126,6 +2163,10 @@ packages:
yaml: yaml:
optional: true optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -2165,6 +2206,8 @@ packages:
snapshots: snapshots:
'@babel/runtime@7.29.7': {}
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
@@ -3347,6 +3390,10 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
http-errors@2.0.1: http-errors@2.0.1:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -3355,6 +3402,10 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
i18next@26.3.1(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
ieee754@1.2.1: {} ieee754@1.2.1: {}
inherits@2.0.4: {} inherits@2.0.4: {}
@@ -3547,6 +3598,17 @@ snapshots:
react: 19.2.7 react: 19.2.7
scheduler: 0.27.0 scheduler: 0.27.0
react-i18next@17.0.8(i18next@26.3.1(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
i18next: 26.3.1(typescript@6.0.3)
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
typescript: 6.0.3
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@@ -3776,6 +3838,8 @@ snapshots:
jiti: 2.7.0 jiti: 2.7.0
tsx: 4.22.4 tsx: 4.22.4
void-elements@3.1.0: {}
wrappy@1.0.2: {} wrappy@1.0.2: {}
ws@8.21.0: {} ws@8.21.0: {}