feat(booth): refusal snapshots, subscriber access medium, one-car-one-ticket entry
Three booth-integrity improvements that share the entry/exit flows and activity log. Refusal snapshots: previously only an accepted open captured a camera image; now every refusal/hold anomaly fires the directional camera too (a turned-away car is exactly the evidence wanted) — entry refused-full/held, exit refused closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription. A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo together. Same fire-and-forget contract; failed captures still show as tiles. Subscriber access medium: the subscription flow already signed `via` (qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only. One car = one ticket: the entry button could be mashed to mint many tickets per car (corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard only blocked overlapping presses. Add a per-relay guard configured on the relay spec: PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input — one ticket per car, re-armed when the loop clears) or COOLDOWN fallback (entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned device_events telemetry, not a signed anomaly. SetupWizard exposes both fields. Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart. Wiki: new entry-double-press; updated entry-exit-points, booth-console, index. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -91,6 +91,16 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
||||
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
||||
function viaKey(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p) return null;
|
||||
if (p.via === "qr") return "booth.viaQr";
|
||||
if (p.via === "card") return "booth.viaCard";
|
||||
if (p.via === "plate") return "booth.viaPlate";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
||||
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
||||
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
||||
@@ -115,8 +125,9 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
||||
const reason = renderReason(p, t);
|
||||
const amount = paymentSummary(p);
|
||||
const badges = eventBadges(p);
|
||||
const via = viaKey(p);
|
||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||
const showDetail = detail != null || badges.length > 0;
|
||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||
|
||||
// The whole row is a button → opens the event-detail modal (full payload + the
|
||||
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
||||
@@ -144,6 +155,11 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
||||
{t(k)}
|
||||
</span>
|
||||
))}
|
||||
{via && (
|
||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||
{t(via)}
|
||||
</span>
|
||||
)}
|
||||
{detail && (
|
||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||
)}
|
||||
@@ -238,6 +254,11 @@ function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void
|
||||
</DetailRow>
|
||||
)}
|
||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||
{viaKey(p) && (
|
||||
<DetailRow label={t("booth.edVia")}>
|
||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
|
||||
Reference in New Issue
Block a user