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",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"i18next": "^26.3.1",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
},
"devDependencies": {
+21 -13
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js";
@@ -14,13 +15,14 @@ import { Panel } from "./ui/Panel.js";
// No payment → no Open barrier button (the no-unpaid-bypass rule).
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { label: string; cls: string } {
if (!s.open && s.withinGrace) return { label: "exiting", cls: "text-term-cyan" };
if (s.paidAt) return { label: "paid", cls: "text-term-green" };
return { label: "unpaid", cls: "text-term-amber" };
function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
}
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: qk.activeSessions,
@@ -48,7 +50,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
setReopenMsg({
id: s.identity,
ok: r.opened,
text: r.opened ? "barrier opened" : r.reason ?? "open manually",
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
});
} catch (e) {
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
@@ -57,13 +59,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
return (
<Panel
title="Active sessions"
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{sessions.length} inside</span>}
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{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) => {
const badge = statusBadge(s);
@@ -77,12 +83,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
type="button"
onClick={() => onPick(s.identity)}
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title="Open pay / exit"
title={t("booth.openPayExit")}
>
<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={`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>
{/* Open barrier — PAID sessions only (no payment, no button). */}
@@ -92,9 +100,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
disabled={reopen.isPending}
onClick={() => handleReopen(s)}
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
title="Human-intervention barrier open (audited)"
title={t("booth.openBarrierTitle")}
>
Open barrier
{t("booth.openBarrier")}
</button>
) : (
<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 { Login } from "./Login.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { router } from "./router.js";
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
@@ -22,6 +23,12 @@ export function App() {
.finally(() => setLoading(false));
}, []);
// Apply the signed-in user's preferred language whenever it resolves/changes
// (login, bootstrap, or a toggle). Albanian is the default before auth resolves.
useEffect(() => {
if (user) setLanguage(user.language);
}, [user]);
if (loading) {
return <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 { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -22,6 +23,7 @@ import { SnapshotStrip } from "./ui/SnapshotStrip.js";
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
@@ -52,13 +54,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
setPhase("finishing");
if (voucher) {
const r = await printVoucher(identity);
setResult(`Exit voucher printed on ${r.printedBy}. Customer self-exits at the exit.`);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity);
setResult(
r.opened
? "Paid — barrier opened. Car may exit."
: `Paid and exit recorded, but the barrier did not open: ${r.reason ?? "open manually"}.`,
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }),
);
}
// Refresh the live views.
@@ -81,25 +83,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
>
<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">
Ticket {identity}
{t("pay.ticket")} {identity}
</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>
</div>
<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 && (
<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>
)}
{s && s.found && !s.open && (
<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>
)}
@@ -107,25 +109,28 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label="Entry" value={formatTime(s.enteredAt)} />
<Row label="Now" value={formatTime(new Date().toISOString())} />
<Row label="Duration" value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"} />
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
<Row
label="Status"
value={alreadyPaid ? "PAID" : "UNPAID"}
label={t("pay.duration")}
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"}
/>
</div>
{/* Total */}
<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">
{s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? "paid"
: "no tariff"}
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
@@ -137,19 +142,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Tender */}
{canPay && (
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">Tender</span>
{(["cash", "card"] as const).map((t) => (
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={t}
key={tn}
type="button"
onClick={() => setTender(t)}
onClick={() => setTender(tn)}
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-border text-term-muted hover:text-term-text"
}`}
>
{t}
{t(`pay.${tn}`)}
</button>
))}
</div>
@@ -162,8 +167,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
Printo biletë dalje
<span className="text-term-muted">(customer self-exits at the exit)</span>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
</>
)}
@@ -181,7 +186,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={onClose}
className="rounded-term border border-term-amber px-4 py-1.5 text-[12px] uppercase tracking-wider text-term-amber"
>
Close
{t("common.close")}
</button>
) : (
<>
@@ -190,7 +195,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
onClick={onClose}
className="rounded-term border border-term-border px-3 py-1.5 text-[12px] uppercase tracking-wider text-term-muted hover:text-term-text"
>
Cancel
{t("common.cancel")}
</button>
<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"
>
{phase === "paying"
? "taking payment…"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? "printing voucher…"
: "opening…"
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? "Print voucher"
: "Open barrier"
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? "Pay + print voucher"
: "Pay + open barrier"}
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
</>
)}
+36 -27
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { qk } from "./lib/query.js";
@@ -13,17 +14,18 @@ import { ActiveSessions } from "./ActiveSessions.js";
// the authoritative numbers; the WS-fed live store overlays real-time updates so
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
/** Per-event-type display: label + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
vehicle_entry: { label: "ENTRY", color: "text-term-green" },
vehicle_exit: { label: "EXIT", color: "text-term-red" },
payment: { label: "PAY", color: "text-term-cyan" },
void: { label: "VOID", color: "text-term-amber" },
barrier_open_command: { label: "OPEN→", color: "text-term-muted" },
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" },
shift_open: { label: "SHIFT+", color: "text-term-amber" },
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" },
anomaly: { label: "ANOMALY", color: "text-term-red" },
/** Per-event-type display: i18n label key + accent colour for the ticker. */
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
function hhmmss(iso: string): string {
@@ -33,6 +35,7 @@ function hhmmss(iso: string): string {
}
function OccupancyGauge({ occ }: { occ: Occupancy }) {
const { t } = useTranslation();
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
return (
@@ -40,13 +43,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
<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="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">
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`}
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
</div>
</div>
<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"}`}>
{occ.free == null ? "∞" : occ.free}
</div>
@@ -59,7 +62,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
)}
{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">
● lot full
{t("booth.lotFull")}
</div>
)}
</div>
@@ -67,11 +70,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
}
function EventRow({ e }: { e: LedgerEvent }) {
const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" };
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
return (
<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={`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="ml-auto text-term-muted">#{e.index}</span>
</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
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const ref = useRef<HTMLInputElement>(null);
return (
@@ -102,7 +108,7 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Scan or type ticket number…"
placeholder={t("booth.scanPlaceholder")}
inputMode="numeric"
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
/>
@@ -110,13 +116,14 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
type="submit"
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
>
Open
{t("booth.open")}
</button>
</form>
);
}
export function BoothScreen() {
const { t } = useTranslation();
// Initial load via Query (also the fallback if the WS is briefly down).
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
@@ -140,18 +147,18 @@ export function BoothScreen() {
<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. */}
<div className="lg:col-span-2">
<Panel title="Process ticket">
<Panel title={t("booth.processTicket")}>
<TicketInput onSubmit={setActiveTicket} />
</Panel>
</div>
{/* Left column: occupancy gauge above the active-sessions list. */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title="Occupancy" right={<StatusDot />}>
<Panel title={t("booth.occupancy")} right={<StatusDot />}>
{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>
<div className="min-h-0 flex-1">
@@ -160,15 +167,17 @@ export function BoothScreen() {
</div>
<Panel
title="Live feed"
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>}
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
{events.length === 0 ? (
<div className="text-term-muted">
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
</div>
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} />)
)}
+6 -4
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { login, type SessionUser } from "./api.js";
export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -22,11 +24,11 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
return (
<main style={{ fontFamily: "system-ui", maxWidth: 320, margin: "4rem auto" }}>
<h1>Parking System</h1>
<h1>{t("auth.title")}</h1>
<form onSubmit={submit}>
<div style={{ margin: "0.5rem 0" }}>
<label>
Username
{t("auth.username")}
<br />
<input
value={username}
@@ -39,7 +41,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div>
<div style={{ margin: "0.5rem 0" }}>
<label>
Password
{t("auth.password")}
<br />
<input
type="password"
@@ -52,7 +54,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
</div>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<button type="submit" disabled={busy || !username || !password}>
{busy ? "Signing in…" : "Sign in"}
{busy ? t("auth.signingIn") : t("auth.signIn")}
</button>
</form>
</main>
+40 -32
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createPermit,
@@ -41,6 +42,12 @@ function formFrom(p: Permit): FormState {
platesText: p.plates.join(", "),
};
}
const STATUS_KEY: Record<Permit["status"], string> = {
active: "permits.statusActive",
suspended: "permits.statusSuspended",
revoked: "permits.statusRevoked",
};
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
@@ -54,6 +61,7 @@ function toInput(f: FormState): PermitInput {
}
export function PermitManager() {
const { t } = useTranslation();
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
@@ -84,19 +92,19 @@ export function PermitManager() {
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: "Permit saved." });
setMsg({ kind: "ok", text: t("permits.permitSaved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
@@ -109,71 +117,71 @@ export function PermitManager() {
return (
<section style={{ marginTop: "2rem" }}>
<h2>Permits</h2>
<h2>{t("permits.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<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>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
<strong>{p.holderName ?? t("permits.unnamed")}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s)
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button>
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
</ul>
{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 }}>
<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" }}>
<label>Holder name</label>
<label>{t("permits.holderName")}</label>
<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 }))} />
<label>Car limit</label>
<label>{t("permits.carLimit")}</label>
<span>
<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>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>Valid from</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
<label>Valid to</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
<label>Bound plates</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
<label>{t("permits.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
{form.credentials.map((c, i) => (
<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" })}>
<option value="rf">RF card/tag</option>
<option value="qr">QR</option>
<option value="rf">{t("permits.rfCardTag")}</option>
<option value="qr">{t("permits.qr")}</option>
</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>
</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" }}>
A permit needs at least one credential OR one bound plate.
{t("permits.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
<button type="button" onClick={save}>{t("permits.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
</div>
</div>
)}
+28 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see
@@ -10,6 +11,7 @@ import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null);
@@ -68,14 +70,14 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg("Enter a positive amount.");
setMoveMsg(t("shift.enterPositive"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
setMoveAmount("");
setMoveReason("");
setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`);
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
@@ -84,27 +86,28 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
return (
<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 ? (
<>
<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}>
{busy ? "Ending…" : "End shift"}
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</>
) : (
<>
<span style={{ color: "#777" }}>not started</span>{" "}
<span style={{ color: "#777" }}>{t("shift.notStarted")}</span>{" "}
<button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"}
{busy ? t("shift.starting") : t("shift.startShift")}
</button>
</>
)}
{/* Live drawer balance (what's in the till right now / inherited). */}
{drawerMinor != null && (
<div style={{ marginTop: "0.5rem", color: "#555" }}>
Drawer: <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>}
{t("shift.drawer")} <strong>{money(drawerMinor, currency)}</strong>
{startedAt && <span style={{ color: "#888" }}> {t("shift.openingFloatInherited")}</span>}
</div>
)}
@@ -114,24 +117,24 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{isAdmin && (
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
Drawer cash (admin) — load or remove the float
{t("shift.drawerCashAdmin")}
</div>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={moveAmount}
onChange={(e) => setMoveAmount(e.target.value)}
placeholder="amount"
placeholder={t("shift.amount")}
inputMode="decimal"
style={{ width: 90 }}
/>
<input
value={moveReason}
onChange={(e) => setMoveReason(e.target.value)}
placeholder="reason (e.g. opening float)"
placeholder={t("shift.reasonPlaceholder")}
style={{ flex: 1, minWidth: 140 }}
/>
<button type="button" onClick={() => move(1)}>Load +</button>
<button type="button" onClick={() => move(-1)}>Remove −</button>
<button type="button" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
</div>
@@ -139,20 +142,20 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
<div>Payments: {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div>
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div>
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div>
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div>
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>{t("shift.zReport")} — {report.operator}</div>
<div>{t("shift.payments")} {report.paymentCount}</div>
<div>{t("shift.cash")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.card")} {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ marginTop: "0.4rem", color: "#666" }}>{t("shift.drawerSection")}</div>
<div>{t("shift.openingFloat")} {money(report.openingFloatMinor, report.currency)}</div>
<div>{t("shift.cashTaken")} {money(report.cashTotalMinor, report.currency)}</div>
<div>{t("shift.cashAdded")} {money(report.cashAddedMinor, report.currency)}</div>
<div>{t("shift.cashRemoved")} {money(report.cashRemovedMinor, report.currency)}</div>
<div style={{ fontWeight: 600 }}>
Expected drawer: {money(report.expectedDrawerMinor, report.currency)}
{t("shift.expectedDrawer")} {money(report.expectedDrawerMinor, report.currency)}
</div>
<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>
)}
+26 -25
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
@@ -7,17 +8,19 @@ import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type S
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
// The optional text fields, in display order, with labels + placeholders.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
{ key: "address", label: "Address", multiline: true },
{ key: "phone", label: "Phone" },
{ key: "email", label: "Email" },
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
// (resolved at render); only `address` is multiline.
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
{ key: "phone", labelKey: "site.fieldPhone" },
{ key: "email", labelKey: "site.fieldEmail" },
];
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [occ, setOcc] = useState<Occupancy | null>(null);
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
@@ -52,7 +55,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
try {
await saveSiteConfig(patch);
reload();
setMsg("Saved.");
setMsg(t("site.saved"));
} catch (e) {
setMsg((e as Error).message);
}
@@ -60,25 +63,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
return (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Occupancy:</strong>{" "}
<strong>{t("site.occupancy")}</strong>{" "}
{occ == null ? (
"…"
) : (
<>
<span style={{ fontWeight: 600 }}>{occ.count}</span>
{occ.capacity != null ? ` / ${occ.capacity}` : " (no capacity set)"}
{occ.capacity != null ? ` / ${occ.capacity}` : ` ${t("site.noCapacitySet")}`}
{occ.capacity != null && (
<span style={{ color: "#666" }}> · {occ.free} free</span>
<span style={{ color: "#666" }}> · {occ.free} {t("site.free")}</span>
)}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>FULL</span>}{" "}
{occ.full && <span style={{ color: "crimson", marginLeft: "0.5rem", fontWeight: 600 }}>{t("site.full")}</span>}{" "}
<button type="button" onClick={reload} style={{ marginLeft: "0.5rem" }}>↻</button>
</>
)}
{canEdit && (
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
<label>
Capacity (blank = no limit):{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
{t("site.capacityLabel")}{" "}
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder={t("site.capacityPlaceholder")} />
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input
@@ -86,35 +89,33 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
checked={exitVoucherDefault}
onChange={(e) => setExitVoucherDefault(e.target.checked)}
/>
Print exit ticket by default
<span style={{ color: "#888", fontSize: "0.8rem" }}>
(booth far from exit → customer self-exits with a voucher)
</span>
{t("site.printExitDefault")}
<span style={{ color: "#888", fontSize: "0.8rem" }}>{t("site.printExitHint")}</span>
</label>
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
Park details (optional — shown on tickets/receipts)
{t("site.parkDetails")}
</div>
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
{label}
{t(labelKey)}
{multiline ? (
<textarea
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
rows={2}
placeholder={placeholder}
placeholder={phKey ? t(phKey) : undefined}
/>
) : (
<input
value={meta[key] ?? ""}
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
placeholder={placeholder}
placeholder={phKey ? t(phKey) : undefined}
/>
)}
</label>
))}
<div>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={save}>{t("site.save")}</button>
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
</div>
</div>
+24 -26
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
fetchTariff,
@@ -78,6 +79,7 @@ function toStructure(f: FormState): TariffStructure {
}
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -112,7 +114,7 @@ export function TariffComposer() {
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
const fresh = await fetchTariff();
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) {
const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
@@ -126,44 +128,40 @@ export function TariffComposer() {
return (
<section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2>
<h2>{t("tariff.title")}</h2>
{!state?.active ? (
<p style={{ color: "#b45309" }}>
No rate card published yet — the pay station can't charge until you publish one.
</p>
<p style={{ color: "#b45309" }}>{t("tariff.noRateCard")}</p>
) : (
<p style={{ color: "#555" }}>
Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "}
{state.versions.length} version(s) in history. Publishing creates a new version; past
sessions keep their original pricing.
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
})}
</p>
)}
<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 }} />
<label>Free entry grace (min)</label>
<label>{t("tariff.freeEntryGrace")}</label>
<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)} />
<label>Daily cap (blank = none)</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
<label>Lost-ticket fee</label>
<label>{t("tariff.dailyCap")}</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
<label>{t("tariff.lostTicketFee")}</label>
<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)} />
</div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
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>
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
<th />
</tr>
</thead>
@@ -174,7 +172,7 @@ export function TariffComposer() {
<input
value={b.uptoMin}
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 }}
/>
</td>
@@ -183,7 +181,7 @@ export function TariffComposer() {
</td>
<td>
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
Remove
{t("tariff.remove")}
</button>
</td>
</tr>
@@ -191,12 +189,12 @@ export function TariffComposer() {
</tbody>
</table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
+ Add block
{t("tariff.addBlock")}
</button>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"}
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button>
</div>
{msg && (
+8
View File
@@ -45,10 +45,13 @@ export class ApiError extends Error {
// --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly";
export type Lang = "sq" | "en";
export interface SessionUser {
id: string;
username: string;
role: Role;
/** Preferred UI language (loaded from the server on login). */
language: Lang;
}
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" });
}
/** 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. */
export async function fetchMe(): Promise<SessionUser | null> {
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 { createRoot } from "react-dom/client";
import "./index.css";
import "./lib/i18n/index.js"; // initialize i18next before the app renders
import { App } from "./App.js";
const rootEl = document.getElementById("root");
+50 -9
View File
@@ -6,9 +6,11 @@ import {
Outlet,
redirect,
} from "@tanstack/react-router";
import type { SessionUser } from "./api.js";
import { logout } from "./api.js";
import { useTranslation } from "react-i18next";
import type { Lang, SessionUser } from "./api.js";
import { logout, setLanguagePref } from "./api.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { StatusDot } from "./ui/StatusDot.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() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
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">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label="Booth" />
<NavLink to="/shift" label="Shift" />
{isAdmin && <NavLink to="/setup" label="Setup" />}
{isAdmin && <NavLink to="/tariff" label="Tariff" />}
{isAdmin && <NavLink to="/permits" label="Permits" />}
{isAdmin && <NavLink to="/site" label="Site" />}
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <LanguageToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.role}
@@ -74,7 +115,7 @@ function RootLayout() {
setUser(null);
}}
>
Log out
{t("common.logout")}
</button>
</div>
</header>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
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.
export function SnapshotStrip({ identity }: { identity: string }) {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity),
@@ -16,8 +18,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const shots = data?.snapshots ?? [];
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no 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">{t("pay.noSnapshots")}</div>;
return (
<>
+7 -5
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// 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",
closed: "bg-term-red",
};
const LABEL: Record<WsStatus, string> = {
open: "LIVE",
connecting: "CONNECTING",
closed: "OFFLINE",
const LABEL_KEY: Record<WsStatus, string> = {
open: "status.live",
connecting: "status.connecting",
closed: "status.offline",
};
export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
{LABEL[status]}
{t(LABEL_KEY[status])}
</span>
);
}