import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { applyValidation, fetchMyValidationPrograms, fetchValidationSession, voidValidation, type SessionUser, type ValidationProgramView, type ValidationSessionView, } from "./api.js"; import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js"; // The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key // the customer's ticket → see the session (deliberately NO money data — the booth // settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the // site LAN, or a booth-style USB HID scanner (it types digits + Enter into the // focused input). A mistake can be voided while UNUSED (append-only, signed). // Gated by validation:create + the server-side program↔user binding. // See wiki/concepts/validation-discounts.md. type Program = Omit; /** Human line for what a program grants (the params live on the program row). */ function programSummary(p: Program, t: (k: string, o?: Record) => string): string { if (p.mode === "comp") return t("val.modeComp"); if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`; if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`; return t("val.modeFixed"); } export function ValidateScreen({ user }: { user: SessionUser }) { const { t } = useTranslation(); const [programs, setPrograms] = useState(null); const [programId, setProgramId] = useState(null); const [ticket, setTicket] = useState(""); const [view, setView] = useState(null); const [amount, setAmount] = useState(""); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); const [busy, setBusy] = useState(false); const inputRef = useRef(null); useEffect(() => { fetchMyValidationPrograms() .then((r) => { setPrograms(r.programs); if (r.programs.length === 1) setProgramId(r.programs[0]!.id); }) .catch(() => setPrograms([])); inputRef.current?.focus(); }, []); const program = programs?.find((p) => p.id === programId) ?? null; async function lookup(id?: string) { const identity = (id ?? ticket).trim(); if (!identity) return; setMsg(null); try { setView(await fetchValidationSession(identity)); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } } async function apply() { if (!view || !program) return; setBusy(true); setMsg(null); try { const body: { identity: string; programId: string; amountMinor?: number } = { identity: view.identity, programId: program.id, }; if (program.mode === "fixed") { const n = Number(amount); body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0; } await applyValidation(body); setMsg({ kind: "ok", text: t("val.applied") }); setAmount(""); await lookup(view.identity); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } finally { setBusy(false); } } async function voidOne(eventId: string) { if (!view) return; if (!window.confirm(t("val.confirmVoid"))) return; setMsg(null); try { await voidValidation({ eventId, identity: view.identity }); await lookup(view.identity); } catch (e) { setMsg({ kind: "err", text: (e as Error).message }); } } // The session's blocking condition, if any (not found / closed / subscriber). const blocked = view == null ? null : !view.found ? t("val.notFound") : view.subscription ? t("val.subscription") : !view.open ? t("val.closed") : null; const alreadyApplied = view != null && program != null && view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null); const fixedAmountOk = program?.mode !== "fixed" || (Number(amount) > 0 && (program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor)); return (
{t("val.title")}
{programs != null && programs.length === 0 && (

{t("val.noPrograms")}

)} {programs != null && programs.length > 1 && (
{programs.map((p) => ( ))}
)} {program &&

{program.name} — {programSummary(program, t)}

}
{ e.preventDefault(); void lookup(); }} > setTicket(e.target.value)} placeholder={t("val.scanPrompt")} />
{msg && (

{msg.text}

)} {view && (
{blocked ? (

{blocked}

) : ( <>
{view.identity} {t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)} {view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}}
{program && !alreadyApplied && (
{program.mode === "fixed" && (
{t("val.amountLabel")} {program.maxAmountMinor != null && ( {t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })} )} setAmount(e.target.value)} placeholder="300" />
)}
)} {view.validations.length > 0 && (
{t("val.existing")}
    {view.validations.map((v) => (
  • {v.label} {v.amountMinor != null && −{formatMoney(v.amountMinor, "")}} {v.minutes != null && {v.minutes} min} {v.percent != null && {v.percent}%} {v.voided ? ( ({t("val.voided")}) ) : v.consumedBy != null ? ( ({t("val.used")}) ) : ( v.operator === user.username && ( ) )}
  • ))}
)} )}
)}
); }