Files
parking_solution/apps/web/src/BoothPayModal.tsx
T
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00

725 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
boothExit,
can,
fetchSiteConfig,
lookupSession,
openShift,
paySession,
printReceipt,
printVoucher,
reopenBarrier,
voidTicket,
type SessionLookup,
} from "./api.js";
import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { Spinner } from "./ui/Spinner.js";
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
// payment, then EITHER prints an exit voucher (customer self-exits at a distant
// exit) OR fires the exit immediately (booth at/near the exit) — controlled by a
// checkbox defaulting from site_config.exitVoucherDefault. See booth-exit-flow.md.
type Phase = "review" | "paying" | "finishing" | "done" | "error";
export function BoothPayModal({ identity, onClose }: { identity: string; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig });
// A shift must be open (and mine) before any pay/exit/voucher action — the booth
// money path is gated. The server enforces this too (409 no_shift); the modal
// surfaces it up front and offers a one-click open. See wiki/concepts/shift.md.
const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift();
const shiftReady = shiftOpen && shiftMine;
const [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false);
// For a PREPAID subscriber with nothing owed, the audited manual barrier open
// (assist a faulty reader / lost card) is no longer the default action — the
// operator reveals it explicitly so the modal isn't an always-on "open" button.
const [assistRevealed, setAssistRevealed] = useState(false);
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
// first, then the modal reveals "Open barrier". This flips true once paid.
const [windowPaid, setWindowPaid] = useState(false);
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
const { user } = rootRoute.useRouteContext();
const canVoid = can(user, "event:void");
const [voiding, setVoiding] = useState(false); // reason prompt revealed
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
const [voidReason, setVoidReason] = useState("");
const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
// exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true;
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
// normal review flow; the only action is an audited manual re-pulse of the barrier.
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
// when it has an amount due. Otherwise the only action is an audited assist-open.
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
const canPay = !!(
shiftReady &&
s?.found &&
s.open &&
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
);
async function handleOpenBarrier() {
if (!s) return;
setError(null);
setPhase("finishing");
try {
const r = await reopenBarrier(identity);
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
// Subscriber out-of-window charge: take the payment, but DON'T exit yet. The
// barrier open is the operator's explicit second step (so the flow reads:
// pay → then Open barrier), mirroring the two-step the operator asked for.
async function handlePaySubscriptionWindow() {
if (!s) return;
setError(null);
setPhase("paying");
try {
await paySession(identity, tender);
setWindowPaid(true);
setPhase("review");
void qc.invalidateQueries({ queryKey: ["session", identity] });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() {
setOpeningShift(true);
setError(null);
try {
await openShift();
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
} finally {
setOpeningShift(false);
}
}
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
// server enforces all of this too; the UI just hides the action when it can't apply.
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
async function handleVoidTicket() {
const reason = voidReason.trim();
if (!reason) return;
setError(null);
setPhase("finishing");
try {
await voidTicket(identity, reason);
setResult(t("pay.ticketCancelled"));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleReprintReceipt() {
setReprinting(true);
setError(null);
try {
const r = await printReceipt(identity);
setResult(t("pay.receiptReprinted", { printer: r.printedBy }));
} catch (e) {
setError((e as Error).message);
} finally {
setReprinting(false);
}
}
async function handlePayAndExit(override = false) {
if (!s) return;
setError(null);
try {
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
// session is "already paid" but a new period accrued — we still charge (canPay
// is true). A settled within-grace session is not payable (canPay false) and is
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
if (canPay && !override) {
setPhase("paying");
await paySession(identity, tender);
}
// 2. Voucher OR immediate exit.
setPhase("finishing");
if (voucher) {
// The voucher slip carries the payment detail + barcode + grace.
const r = await printVoucher(identity);
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
} else {
const r = await boothExit(identity, override);
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
if (!r.ok) {
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
setPhase("review");
return;
}
setSwap(null);
// No voucher → auto-print a standalone payment receipt for transparency.
// Best-effort: a printer fault must NOT block the exit that already happened;
// the operator can reprint from the done screen.
let receiptNote = "";
try {
await printReceipt(identity);
} catch {
receiptNote = ` ${t("pay.receiptPrintFailed")}`;
}
setResult(
(r.opened
? t("pay.paidBarrierOpened")
: t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") })) +
receiptNote,
);
}
// Refresh the live views.
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/70" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[560px] max-w-[95vw] -translate-x-1/2 -translate-y-1/2 rounded-term border border-term-border bg-term-panel font-mono text-term-text shadow-2xl"
aria-describedby={undefined}
>
<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-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
</Dialog.Close>
</div>
<div className="flex flex-col gap-3 p-4">
{/* Shift gate — block all actions until THIS operator has a shift open.
Another operator's open shift can't be operated under (no shared
till); only an "open mine" path when no shift is open at all. */}
{!shiftReady && (
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[0.75rem] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="btn btn-go btn-sm mt-2"
>
{openingShift ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.opening")}
</span>
) : (
t("shift.openNow")
)}
</button>
</>
)}
</div>
)}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && (
<div className="rounded-term border border-term-red px-3 py-2 text-term-red">
{t("pay.noSessionFound")}
</div>
)}
{s && s.found && !s.open && !closedWithinGrace && (
// A fully-closed session (exited, grace expired): no action to take, but the
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
// — e.g. a dispute about a car that just left. Show the closed notice, the
// figures, and the snapshot strip read-only. No tender / voucher / open here.
<>
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
<Row
label={t("pay.duration")}
value={
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
}
/>
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
)}
</div>
<SnapshotStrip identity={identity} />
</>
)}
{s && s.found && (s.open || closedWithinGrace) && (
<>
{/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
<Row
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
value={formatRelativeDateTime(
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
t,
{ seconds: true },
)}
/>
<Row
label={t("pay.duration")}
value={
s.enteredAt
? formatDuration(
s.enteredAt,
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
)
: "—"
}
/>
<Row
label={t("pay.statusLabel")}
value={
isSubscription
? t("pay.subscription")
: isOverstay
? t("pay.overstay")
: closedWithinGrace
? t("pay.closedWithinGrace")
: alreadyPaid
? t("pay.paid")
: t("pay.unpaid")
}
valueClass={
isSubscription
? "text-term-cyan"
: isOverstay
? "text-term-red"
: closedWithinGrace
? "text-term-amber"
: alreadyPaid
? "text-term-green"
: "text-term-amber"
}
/>
</div>
{/* Merchant validations (bar/lavazh): the gross fee + one line per
discount — the Total below is the NET the customer pays. The lines
ride the quote (SessionLookup.validationLines) and reprint on the
receipt. See wiki/concepts/validation-discounts.md. */}
{!isSubscription &&
(s.validationLines ?? []).length > 0 &&
s.currency != null &&
s.amountMinor != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between text-term-text">
<span>{t("val.gross")}</span>
<span className="tabular-nums">
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
</span>
</div>
{(s.validationLines ?? []).map((v, i) => (
<div key={i} className="flex justify-between text-term-green">
<span>{v.label}</span>
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{subWindowDue
? t("pay.windowCharge")
: isSubscription
? t("pay.plan")
: isOverstay
? t("pay.topUp")
: alreadyPaid && s.paidMinor != null
? // Settled session — the figure is the sum collected, not a quote.
t("pay.paidAmount")
: t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{subWindowDue && s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid && s.paidMinor != null && s.paidCurrency
? // Settled (within-grace / closed): show the sum actually collected.
formatMoney(s.paidMinor, s.paidCurrency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* Subscription guidance: an unpaid window charge explains the pay-first
gate; once paid, prompt the operator to open the barrier; a prepaid
subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowChargeHint")}
</div>
) : isSubscription && windowPaid ? (
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.windowPaidHint")}
</div>
) : isSubscription && assistRevealed ? (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.subAssistHint")}
</div>
) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.overstayHint")}
</div>
)}
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
just didn't confirm. Explain that the only action is a manual re-pulse. */}
{closedWithinGrace && (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
{t("pay.closedWithinGraceHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{/* Tender — shown for any payable case (transient, overstay, OR a
subscriber window charge that's still unpaid). Card is hidden until a
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
lib/features.ts + wiki/concepts/card-payments.md. */}
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
<div className="flex items-center gap-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
{(["cash", "card"] as const).map((tn) => (
<button
key={tn}
type="button"
onClick={() => setTender(tn)}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.${tn}`)}
</button>
))}
</div>
)}
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
for a closed-within-grace session — its exit is already recorded. */}
{phase !== "done" && !isSubscription && !closedWithinGrace && (
<label className="flex items-center gap-2 text-[0.75rem]">
<input
type="checkbox"
className="accent-term-amber"
checked={voucher}
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
/>
{t("pay.printExitVoucher")}
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
</label>
)}
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
A few presets + free text; a reason is REQUIRED. Voiding appends a
signed `void` event — the entry is never edited. */}
{voiding && phase !== "done" && (
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.cancelTicketTitle")}
</div>
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
>
{t(`pay.cancelReason.${k}`)}
</button>
))}
</div>
<input
className="input mt-2 w-full"
value={voidReason}
onChange={(e) => setVoidReason(e.target.value)}
placeholder={t("pay.cancelReasonPlaceholder")}
/>
</div>
)}
{/* PLATE-SWAP warning: the exiting plate is already inside under another
ticket. A prominent, deliberate hold — the operator must consciously
override to release. See wiki/concepts/plate-reconciliation.md. */}
{swap && (
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
{t("pay.swapTitle")}
</div>
<div className="mt-1 text-[0.75rem] text-term-text">
{t("pay.swapBody", {
plate: swap.plate,
other: swap.otherIdentity,
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
})}
</div>
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
</div>
)}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
{result && (
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
{phase === "done" ? (
<>
{/* Reprint the payment receipt (slip jammed / customer asks).
Only for a charged session — a subscription has no payment. */}
{!isSubscription && (
<button
type="button"
onClick={handleReprintReceipt}
disabled={reprinting}
className="btn btn-sm"
>
{reprinting ? t("pay.reprinting") : t("pay.reprintReceipt")}
</button>
)}
<button
type="button"
onClick={onClose}
className="btn btn-primary btn-sm"
>
{t("common.close")}
</button>
</>
) : (
<>
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm"
>
{t("common.cancel")}
</button>
{closedWithinGrace ? (
// Paid + exited but the barrier didn't confirm — the only action is
// an audited manual re-pulse (the server re-opens without signing a
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : isSubscription ? (
subWindowDue && !windowPaid ? (
// Step 1 — a window charge is owed: take payment first. The
// barrier open is the explicit next step (revealed once paid).
<button
type="button"
onClick={handlePaySubscriptionWindow}
disabled={!shiftReady || phase === "paying"}
className="btn btn-go btn-lg"
>
{phase === "paying" ? t("pay.takingPayment") : t("pay.payWindowCharge")}
</button>
) : windowPaid || assistRevealed ? (
// The audited barrier open. Shown only AFTER a window charge is
// settled, or after the operator explicitly reveals the assist —
// never as the default action for a prepaid subscriber.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
// Prepaid, nothing owed: no default open. A small reveal exposes
// the audited manual open for a faulty reader / lost card.
<button
type="button"
onClick={() => setAssistRevealed(true)}
disabled={!shiftReady}
className="btn btn-ghost btn-sm"
>
{t("pay.assistOpenReveal")}
</button>
)
) : voiding ? (
// Cancel-ticket confirm (reason prompt is shown above).
<button
type="button"
onClick={handleVoidTicket}
disabled={!voidReason.trim() || phase === "finishing"}
className="btn btn-danger btn-lg"
>
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
</button>
) : (
<>
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
gated on event:void). Reveals the reason prompt above. */}
{canCancel && (
<button
type="button"
onClick={() => setVoiding(true)}
className="btn btn-ghost btn-sm text-term-red"
>
{t("pay.cancelTicket")}
</button>
)}
{swap ? (
// Plate-swap held → the only forward action is a conscious
// override (re-submit with override:true; payment already taken).
<button
type="button"
onClick={() => handlePayAndExit(true)}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-danger btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
</button>
) : (
<button
type="button"
onClick={() => handlePayAndExit()}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="btn btn-go btn-lg"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-baseline justify-between">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className={`text-sm ${valueClass}`}>{value}</span>
</div>
);
}