14c83e182a
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).
63 lines
2.3 KiB
TypeScript
63 lines
2.3 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
|
|
|
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
|
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
|
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
|
|
|
export function SnapshotStrip({ identity }: { identity: string }) {
|
|
const { t } = useTranslation();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["snapshots", identity],
|
|
queryFn: () => fetchSnapshots(identity),
|
|
enabled: !!identity,
|
|
});
|
|
const [zoom, setZoom] = useState<string | null>(null);
|
|
|
|
const shots = data?.snapshots ?? [];
|
|
|
|
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
|
|
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
|
|
|
|
return (
|
|
<>
|
|
<div className="flex gap-2">
|
|
{shots.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() => setZoom(s.id)}
|
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
|
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
|
|
>
|
|
<img
|
|
src={snapshotImageUrl(s.id)}
|
|
alt={s.direction ?? "snapshot"}
|
|
className="h-20 w-28 object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<span
|
|
className={`text-[9px] uppercase tracking-wider ${
|
|
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
|
}`}
|
|
>
|
|
{s.direction ?? "—"}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{zoom && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
|
onClick={() => setZoom(null)}
|
|
>
|
|
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|