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:
@@ -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": {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// English (en). Mirrors the key structure of sq.ts (the default/fallback). Any key
|
||||||
|
// missing here falls back to Albanian. See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
import type { Catalog } from "./sq.js";
|
||||||
|
|
||||||
|
export const en: Catalog = {
|
||||||
|
common: {
|
||||||
|
loading: "Loading…",
|
||||||
|
logout: "Log out",
|
||||||
|
cancel: "Cancel",
|
||||||
|
close: "Close",
|
||||||
|
save: "Save",
|
||||||
|
none: "—",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Parking System",
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
signIn: "Sign in",
|
||||||
|
signingIn: "Signing in…",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
booth: "Booth",
|
||||||
|
shift: "Shift",
|
||||||
|
setup: "Setup",
|
||||||
|
tariff: "Tariff",
|
||||||
|
permits: "Permits",
|
||||||
|
site: "Site",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "LIVE",
|
||||||
|
connecting: "CONNECTING",
|
||||||
|
offline: "OFFLINE",
|
||||||
|
},
|
||||||
|
booth: {
|
||||||
|
processTicket: "Process ticket",
|
||||||
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
|
open: "Open",
|
||||||
|
occupancy: "Occupancy",
|
||||||
|
occUnavailable: "occupancy unavailable",
|
||||||
|
inside: "inside",
|
||||||
|
of: "of",
|
||||||
|
uncapped: "uncapped",
|
||||||
|
free: "free",
|
||||||
|
lotFull: "● lot full",
|
||||||
|
liveFeed: "Live feed",
|
||||||
|
events: "events",
|
||||||
|
noEventsYet: "No events yet — entries and exits will stream here.",
|
||||||
|
activeSessions: "Active sessions",
|
||||||
|
insideCount: "inside",
|
||||||
|
noActiveSessions: "No active sessions.",
|
||||||
|
inAt: "in",
|
||||||
|
openPayExit: "Open pay / exit",
|
||||||
|
openBarrier: "Open barrier",
|
||||||
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
|
barrierOpened: "barrier opened",
|
||||||
|
openManually: "open manually",
|
||||||
|
badgeExiting: "exiting",
|
||||||
|
badgePaid: "paid",
|
||||||
|
badgeUnpaid: "unpaid",
|
||||||
|
evtEntry: "ENTRY",
|
||||||
|
evtExit: "EXIT",
|
||||||
|
evtPay: "PAY",
|
||||||
|
evtVoid: "VOID",
|
||||||
|
evtOpenCmd: "OPEN→",
|
||||||
|
evtOpenObserved: "OPEN✓",
|
||||||
|
evtShiftOpen: "SHIFT+",
|
||||||
|
evtShiftZ: "SHIFT Z",
|
||||||
|
evtCashMovement: "CASH",
|
||||||
|
evtAnomaly: "ANOMALY",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tariff",
|
||||||
|
noRateCard: "No rate card published yet — the pay station can't charge until you publish one.",
|
||||||
|
activeSince: "Active since {{date}} · {{count}} version(s) in history. Publishing creates a new version; past sessions keep their original pricing.",
|
||||||
|
currency: "Currency",
|
||||||
|
freeEntryGrace: "Free entry grace (min)",
|
||||||
|
billingIncrement: "Billing increment (min)",
|
||||||
|
dailyCap: "Daily cap (blank = none)",
|
||||||
|
dailyCapPh: "e.g. 12.00",
|
||||||
|
lostTicketFee: "Lost-ticket fee",
|
||||||
|
exitGrace: "Exit walk-back grace (min)",
|
||||||
|
rateBlocks: "Rate blocks",
|
||||||
|
rateBlocksHint: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||||
|
upToMin: "Up to (min)",
|
||||||
|
pricePerIncrement: "Price / increment",
|
||||||
|
thereafter: "thereafter",
|
||||||
|
egExample: "e.g. 60",
|
||||||
|
remove: "Remove",
|
||||||
|
addBlock: "+ Add block",
|
||||||
|
publishNewVersion: "Publish new version",
|
||||||
|
publishing: "Publishing…",
|
||||||
|
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Permits",
|
||||||
|
unnamed: "(unnamed)",
|
||||||
|
unbound: "unbound",
|
||||||
|
car_one: "{{count}} car",
|
||||||
|
car_other: "{{count}} cars",
|
||||||
|
cred: "cred",
|
||||||
|
plates: "{{count}} plate(s)",
|
||||||
|
edit: "Edit",
|
||||||
|
revoke: "Revoke",
|
||||||
|
delete: "Delete",
|
||||||
|
noPermitsYet: "No permits yet.",
|
||||||
|
addPermit: "+ Add permit",
|
||||||
|
newPermit: "New permit",
|
||||||
|
editPermit: "Edit permit",
|
||||||
|
holderName: "Holder name",
|
||||||
|
contact: "Contact",
|
||||||
|
carLimit: "Car limit",
|
||||||
|
limitCarsInAtOnce: "limit cars in at once",
|
||||||
|
validFrom: "Valid from",
|
||||||
|
validTo: "Valid to",
|
||||||
|
isoDateOptional: "ISO date (optional)",
|
||||||
|
boundPlates: "Bound plates",
|
||||||
|
commaSeparatedOptional: "comma-separated (optional)",
|
||||||
|
credentialsCardQr: "Credentials (card / QR)",
|
||||||
|
rfCardTag: "RF card/tag",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "credential value",
|
||||||
|
addCredential: "+ credential",
|
||||||
|
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
permitSaved: "Permit saved.",
|
||||||
|
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
|
||||||
|
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)",
|
||||||
|
statusActive: "active",
|
||||||
|
statusSuspended: "suspended",
|
||||||
|
statusRevoked: "revoked",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Occupancy:",
|
||||||
|
noCapacitySet: "(no capacity set)",
|
||||||
|
free: "free",
|
||||||
|
full: "FULL",
|
||||||
|
capacityLabel: "Capacity (blank = no limit):",
|
||||||
|
capacityPlaceholder: "e.g. 120",
|
||||||
|
printExitDefault: "Print exit ticket by default",
|
||||||
|
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||||
|
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||||
|
save: "Save",
|
||||||
|
saved: "Saved.",
|
||||||
|
fieldParkName: "Park name",
|
||||||
|
fieldParkNamePh: "e.g. Acme Parking",
|
||||||
|
fieldOperator: "Operator (legal name)",
|
||||||
|
fieldOperatorPh: "operating company",
|
||||||
|
fieldNius: "NIUS",
|
||||||
|
fieldNiusPh: "e.g. L01234567A",
|
||||||
|
fieldAddress: "Address",
|
||||||
|
fieldPhone: "Phone",
|
||||||
|
fieldEmail: "Email",
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
label: "Shift:",
|
||||||
|
open: "open",
|
||||||
|
notStarted: "not started",
|
||||||
|
since: "since",
|
||||||
|
startShift: "Start shift",
|
||||||
|
starting: "Starting…",
|
||||||
|
endShift: "End shift",
|
||||||
|
ending: "Ending…",
|
||||||
|
drawer: "Drawer:",
|
||||||
|
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||||
|
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||||
|
amount: "amount",
|
||||||
|
reasonPlaceholder: "reason (e.g. opening float)",
|
||||||
|
load: "Load +",
|
||||||
|
remove: "Remove −",
|
||||||
|
enterPositive: "Enter a positive amount.",
|
||||||
|
drawerNow: "Drawer now {{amount}}.",
|
||||||
|
zReport: "Z-REPORT",
|
||||||
|
payments: "Payments:",
|
||||||
|
cash: "Cash:",
|
||||||
|
card: "Card:",
|
||||||
|
drawerSection: "— Drawer —",
|
||||||
|
openingFloat: "Opening float:",
|
||||||
|
cashTaken: "Cash taken:",
|
||||||
|
cashAdded: "Cash added:",
|
||||||
|
cashRemoved: "Cash removed:",
|
||||||
|
expectedDrawer: "Expected drawer:",
|
||||||
|
printedToReceipt: "Printed to booth receipt.",
|
||||||
|
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||||
|
},
|
||||||
|
pay: {
|
||||||
|
ticket: "Ticket",
|
||||||
|
entry: "Entry",
|
||||||
|
now: "Now",
|
||||||
|
duration: "Duration",
|
||||||
|
statusLabel: "Status",
|
||||||
|
paid: "PAID",
|
||||||
|
unpaid: "UNPAID",
|
||||||
|
total: "Total",
|
||||||
|
noTariff: "no tariff",
|
||||||
|
tender: "Tender",
|
||||||
|
cash: "Cash",
|
||||||
|
card: "Card",
|
||||||
|
printExitVoucher: "Print exit ticket",
|
||||||
|
selfExitHint: "(customer self-exits at the exit)",
|
||||||
|
payAndOpen: "Pay + open barrier",
|
||||||
|
payAndVoucher: "Pay + print voucher",
|
||||||
|
openBarrier: "Open barrier",
|
||||||
|
printVoucher: "Print voucher",
|
||||||
|
takingPayment: "taking payment…",
|
||||||
|
printingVoucher: "printing voucher…",
|
||||||
|
opening: "opening…",
|
||||||
|
noSessionFound: "No session found for this ticket.",
|
||||||
|
alreadyClosed: "This session is already closed (exited {{time}}).",
|
||||||
|
lookingUp: "looking up…",
|
||||||
|
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||||
|
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||||
|
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||||
|
noSnapshots: "no snapshots",
|
||||||
|
loadingSnapshots: "loading snapshots…",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { sq } from "./sq.js";
|
||||||
|
import { en } from "./en.js";
|
||||||
|
|
||||||
|
// i18next setup for the operator UI. Albanian (sq) is the DEFAULT and the fallback;
|
||||||
|
// English (en) is the second language. The active language is the LOGGED-IN USER's
|
||||||
|
// stored preference (users.language), applied via setLanguage() after auth resolves
|
||||||
|
// — not localStorage, not the browser. Printed tickets are NOT governed by this
|
||||||
|
// (always Albanian, customer-facing). See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
export type Lang = "sq" | "en";
|
||||||
|
|
||||||
|
// Single flat namespace; keys are dot-paths (e.g. "booth.processTicket"). Nested
|
||||||
|
// objects in the catalogs are walked by i18next's keySeparator.
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
sq: { translation: sq },
|
||||||
|
en: { translation: en },
|
||||||
|
},
|
||||||
|
lng: "sq",
|
||||||
|
fallbackLng: "sq",
|
||||||
|
interpolation: { escapeValue: false }, // React already escapes
|
||||||
|
returnNull: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Apply a language (e.g. after login resolves the user's preference). No-op if
|
||||||
|
* already active. */
|
||||||
|
export function setLanguage(lang: Lang): void {
|
||||||
|
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Albanian (sq) — the DEFAULT and fallback language. Customer/operator-facing copy.
|
||||||
|
// Keys are dot-namespaced by area (common, nav, booth, …). When adding a string,
|
||||||
|
// add it here first (the fallback), then mirror the key in en.ts.
|
||||||
|
// See wiki/concepts/i18n.md.
|
||||||
|
|
||||||
|
export const sq = {
|
||||||
|
common: {
|
||||||
|
loading: "Duke u ngarkuar…",
|
||||||
|
logout: "Dil",
|
||||||
|
cancel: "Anulo",
|
||||||
|
close: "Mbyll",
|
||||||
|
save: "Ruaj",
|
||||||
|
none: "—",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Sistemi i Parkimit",
|
||||||
|
username: "Përdoruesi",
|
||||||
|
password: "Fjalëkalimi",
|
||||||
|
signIn: "Hyr",
|
||||||
|
signingIn: "Duke hyrë…",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
booth: "Kabina",
|
||||||
|
shift: "Turni",
|
||||||
|
setup: "Konfigurimi",
|
||||||
|
tariff: "Tarifa",
|
||||||
|
permits: "Lejet",
|
||||||
|
site: "Vendi",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
live: "DREJTPËRDREJT",
|
||||||
|
connecting: "DUKE U LIDHUR",
|
||||||
|
offline: "JASHTË LINJE",
|
||||||
|
},
|
||||||
|
booth: {
|
||||||
|
processTicket: "Proceso biletën",
|
||||||
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
|
open: "Hap",
|
||||||
|
occupancy: "Zënia",
|
||||||
|
occUnavailable: "zënia e padisponueshme",
|
||||||
|
inside: "brenda",
|
||||||
|
of: "nga",
|
||||||
|
uncapped: "pa kufi",
|
||||||
|
free: "lirë",
|
||||||
|
lotFull: "● parkimi plot",
|
||||||
|
liveFeed: "Aktiviteti i drejtpërdrejtë",
|
||||||
|
events: "ngjarje",
|
||||||
|
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||||
|
activeSessions: "Sesionet aktive",
|
||||||
|
insideCount: "brenda",
|
||||||
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
inAt: "në",
|
||||||
|
openPayExit: "Hap pagesën / daljen",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
|
||||||
|
barrierOpened: "barriera u hap",
|
||||||
|
openManually: "hape me dorë",
|
||||||
|
// session row badges
|
||||||
|
badgeExiting: "duke dalë",
|
||||||
|
badgePaid: "paguar",
|
||||||
|
badgeUnpaid: "papaguar",
|
||||||
|
// event types (live feed labels)
|
||||||
|
evtEntry: "HYRJE",
|
||||||
|
evtExit: "DALJE",
|
||||||
|
evtPay: "PAGESË",
|
||||||
|
evtVoid: "ANULIM",
|
||||||
|
evtOpenCmd: "HAP→",
|
||||||
|
evtOpenObserved: "HAP✓",
|
||||||
|
evtShiftOpen: "TURN+",
|
||||||
|
evtShiftZ: "TURN Z",
|
||||||
|
evtCashMovement: "ARKË",
|
||||||
|
evtAnomaly: "ANOMALI",
|
||||||
|
},
|
||||||
|
tariff: {
|
||||||
|
title: "Tarifa",
|
||||||
|
noRateCard: "Asnjë kartë tarifore e publikuar — arka nuk mund të faturojë derisa të publikoni një.",
|
||||||
|
activeSince: "Aktive që nga {{date}} · {{count}} version(e) në histori. Publikimi krijon një version të ri; sesionet e kaluara ruajnë çmimin origjinal.",
|
||||||
|
currency: "Monedha",
|
||||||
|
freeEntryGrace: "Periudha pa pagesë në hyrje (min)",
|
||||||
|
billingIncrement: "Intervali i faturimit (min)",
|
||||||
|
dailyCap: "Kufiri ditor (bosh = pa kufi)",
|
||||||
|
dailyCapPh: "p.sh. 12.00",
|
||||||
|
lostTicketFee: "Tarifa për biletë të humbur",
|
||||||
|
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||||
|
rateBlocks: "Blloqet tarifore",
|
||||||
|
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||||
|
upToMin: "Deri në (min)",
|
||||||
|
pricePerIncrement: "Çmimi / interval",
|
||||||
|
thereafter: "më pas",
|
||||||
|
egExample: "p.sh. 60",
|
||||||
|
remove: "Hiq",
|
||||||
|
addBlock: "+ Shto bllok",
|
||||||
|
publishNewVersion: "Publiko version të ri",
|
||||||
|
publishing: "Duke publikuar…",
|
||||||
|
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||||
|
},
|
||||||
|
permits: {
|
||||||
|
title: "Lejet",
|
||||||
|
unnamed: "(pa emër)",
|
||||||
|
unbound: "pa kufizim",
|
||||||
|
car_one: "{{count}} makinë",
|
||||||
|
car_other: "{{count}} makina",
|
||||||
|
cred: "kredencial",
|
||||||
|
plates: "{{count}} targë(a)",
|
||||||
|
edit: "Ndrysho",
|
||||||
|
revoke: "Anulo",
|
||||||
|
delete: "Fshij",
|
||||||
|
noPermitsYet: "Asnjë leje ende.",
|
||||||
|
addPermit: "+ Shto leje",
|
||||||
|
newPermit: "Leje e re",
|
||||||
|
editPermit: "Ndrysho lejen",
|
||||||
|
holderName: "Emri i mbajtësit",
|
||||||
|
contact: "Kontakti",
|
||||||
|
carLimit: "Kufiri i makinave",
|
||||||
|
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||||
|
validFrom: "Vlen nga",
|
||||||
|
validTo: "Vlen deri",
|
||||||
|
isoDateOptional: "Datë ISO (opsionale)",
|
||||||
|
boundPlates: "Targat e lidhura",
|
||||||
|
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||||
|
credentialsCardQr: "Kredencialet (kartë / QR)",
|
||||||
|
rfCardTag: "Kartë/etiketë RF",
|
||||||
|
qr: "QR",
|
||||||
|
credentialValue: "vlera e kredencialit",
|
||||||
|
addCredential: "+ kredencial",
|
||||||
|
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.",
|
||||||
|
save: "Ruaj",
|
||||||
|
cancel: "Anulo",
|
||||||
|
permitSaved: "Leja u ruajt.",
|
||||||
|
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.",
|
||||||
|
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||||
|
statusActive: "aktive",
|
||||||
|
statusSuspended: "pezulluar",
|
||||||
|
statusRevoked: "anuluar",
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
occupancy: "Zënia:",
|
||||||
|
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||||
|
free: "lirë",
|
||||||
|
full: "PLOT",
|
||||||
|
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||||
|
capacityPlaceholder: "p.sh. 120",
|
||||||
|
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||||
|
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
||||||
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
fieldParkName: "Emri i parkimit",
|
||||||
|
fieldParkNamePh: "p.sh. Acme Parking",
|
||||||
|
fieldOperator: "Operatori (emri ligjor)",
|
||||||
|
fieldOperatorPh: "kompania operuese",
|
||||||
|
fieldNius: "NIUS",
|
||||||
|
fieldNiusPh: "p.sh. L01234567A",
|
||||||
|
fieldAddress: "Adresa",
|
||||||
|
fieldPhone: "Telefoni",
|
||||||
|
fieldEmail: "Email",
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
label: "Turni:",
|
||||||
|
open: "hapur",
|
||||||
|
notStarted: "i panisur",
|
||||||
|
since: "që nga",
|
||||||
|
startShift: "Fillo turnin",
|
||||||
|
starting: "Duke filluar…",
|
||||||
|
endShift: "Mbyll turnin",
|
||||||
|
ending: "Duke mbyllur…",
|
||||||
|
drawer: "Arka:",
|
||||||
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
|
amount: "shuma",
|
||||||
|
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||||
|
load: "Shto +",
|
||||||
|
remove: "Hiq −",
|
||||||
|
enterPositive: "Shkruaj një shumë pozitive.",
|
||||||
|
drawerNow: "Arka tani {{amount}}.",
|
||||||
|
zReport: "RAPORT Z",
|
||||||
|
payments: "Pagesa:",
|
||||||
|
cash: "Para:",
|
||||||
|
card: "Kartë:",
|
||||||
|
drawerSection: "— Arka —",
|
||||||
|
openingFloat: "Bilanci fillestar:",
|
||||||
|
cashTaken: "Para të marra:",
|
||||||
|
cashAdded: "Para të shtuara:",
|
||||||
|
cashRemoved: "Para të hequra:",
|
||||||
|
expectedDrawer: "Arka e pritshme:",
|
||||||
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
|
},
|
||||||
|
pay: {
|
||||||
|
ticket: "Bileta",
|
||||||
|
entry: "Hyrja",
|
||||||
|
now: "Tani",
|
||||||
|
duration: "Kohëzgjatja",
|
||||||
|
statusLabel: "Statusi",
|
||||||
|
paid: "PAGUAR",
|
||||||
|
unpaid: "PAPAGUAR",
|
||||||
|
total: "Totali",
|
||||||
|
noTariff: "pa tarifë",
|
||||||
|
tender: "Mënyra",
|
||||||
|
cash: "Para",
|
||||||
|
card: "Kartë",
|
||||||
|
printExitVoucher: "Printo biletë dalje",
|
||||||
|
selfExitHint: "(klienti del vetë te dalja)",
|
||||||
|
payAndOpen: "Paguaj + hap barrierën",
|
||||||
|
payAndVoucher: "Paguaj + printo biletën",
|
||||||
|
openBarrier: "Hap barrierën",
|
||||||
|
printVoucher: "Printo biletën",
|
||||||
|
takingPayment: "Duke marrë pagesën…",
|
||||||
|
printingVoucher: "Duke printuar biletën…",
|
||||||
|
opening: "Duke hapur…",
|
||||||
|
noSessionFound: "Nuk u gjet asnjë sesion për këtë biletë.",
|
||||||
|
alreadyClosed: "Ky sesion është mbyllur tashmë (doli {{time}}).",
|
||||||
|
lookingUp: "Duke kërkuar…",
|
||||||
|
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||||
|
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||||
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||||
|
// snapshots
|
||||||
|
noSnapshots: "asnjë foto",
|
||||||
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The catalog SHAPE (keys + nesting), with string-typed values — so en.ts must
|
||||||
|
// supply every key but may differ in value. (Not `typeof sq` with `as const`, which
|
||||||
|
// would pin en.ts to the Albanian literals.)
|
||||||
|
type Stringify<T> = { [K in keyof T]: T[K] extends object ? Stringify<T[K]> : string };
|
||||||
|
export type Catalog = Stringify<typeof sq>;
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+64
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user