diff --git a/frontend/src/app/admin/scanner/_lib/feedback.ts b/frontend/src/app/admin/scanner/_lib/feedback.ts index 694cec7..1c975b3 100644 --- a/frontend/src/app/admin/scanner/_lib/feedback.ts +++ b/frontend/src/app/admin/scanner/_lib/feedback.ts @@ -1,3 +1,5 @@ +import toast from 'react-hot-toast'; + // Haptic + audio confirmation. At a loud, dark door the sound and the buzz are // what staff actually register — the green flash is confirmation for the person // standing in front of them. @@ -39,3 +41,18 @@ export function vibrate(pattern: number | number[]) { if (navigator.vibrate) navigator.vibrate(pattern); } catch {} } + +/** + * Dismiss a toast after `ms` regardless of react-hot-toast's hover/touch pause. + * + * The pauses every countdown while the pointer is "inside" its + * container, and schedules no dismissal at all for toasts created while paused. + * On a touchscreen, tapping a toast button (the Undo toast) synthesizes a + * mouseenter with no mouseleave until the next tap somewhere else — so the + * "Undone" toast that follows would sit there indefinitely. Callers create the + * toast with `duration: Infinity` and let this timer close it. + */ +export function dismissAfter(id: string, ms: number): string { + window.setTimeout(() => toast.dismiss(id), ms); + return id; +} diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index f4e8c37..29c057d 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -26,7 +26,7 @@ import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { buildIndex, searchAttendees, hasExactMatch } from './_lib/search'; import { newIdempotencyKey, submitDoorAction, undoDoorAction } from './_lib/doorActions'; -import { playSuccessSound, playErrorSound, vibrate } from './_lib/feedback'; +import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/feedback'; import { QRScannerOverlay } from './_components/QRScannerOverlay'; import { AttendeeRow } from './_components/AttendeeRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; @@ -48,6 +48,10 @@ import { ResultScreen, type DoorResult } from './_components/ResultScreen'; const REFRESH_INTERVAL_MS = 30_000; const FLASH_MS = 900; const UNDO_WINDOW_MS = 10_000; +// Every toast on this screen is closed by our own timer (dismissAfter) rather +// than react-hot-toast's countdown, which pauses while a toast is touched and +// never resumes on a touchscreen until the next tap elsewhere. +const TOAST_MS = 4000; const clockTime = (value: Date | string) => (typeof value === 'string' ? parseDate(value) : value).toLocaleTimeString([], { @@ -250,12 +254,18 @@ export default function AdminDoorPage() { try { await undoDoorAction(eventId, entry.idempotencyKey); - toast.success(`Undone — ${entry.name}`, { position: 'bottom-center' }); + dismissAfter( + toast.success(`Undone — ${entry.name}`, { position: 'bottom-center', duration: Infinity }), + TOAST_MS, + ); } catch (error: any) { - toast.error(`Could not undo ${entry.name}: ${error?.message || 'request failed'}`, { - position: 'bottom-center', - duration: 8000, - }); + dismissAfter( + toast.error(`Could not undo ${entry.name}: ${error?.message || 'request failed'}`, { + position: 'bottom-center', + duration: Infinity, + }), + 8000, + ); } finally { // Whatever happened, the server is the truth about who is inside. loadAttendees(eventId, { silent: true }); @@ -293,8 +303,9 @@ export default function AdminDoorPage() { ), - { duration: durationMs, position: 'bottom-center' }, + { duration: Infinity, position: 'bottom-center' }, ); + dismissAfter(id, durationMs); undoToastIdsRef.current.set(entry.idempotencyKey, id); }, [handleUndo], @@ -430,7 +441,10 @@ export default function AdminDoorPage() { ); if (res.warnings?.includes('at_capacity') && !capacityWarnedRef.current) { capacityWarnedRef.current = true; - toast('Event at capacity, added anyway', { icon: '⚠️', position: 'bottom-center', duration: 6000 }); + dismissAfter( + toast('Event at capacity, added anyway', { icon: '⚠️', position: 'bottom-center', duration: Infinity }), + 6000, + ); } } catch (error: any) { // Retries are exhausted: make the failure loud and name the person, so @@ -457,10 +471,13 @@ export default function AdminDoorPage() { detail: error?.message || 'The check-in did not reach the server. Try again.', }); } - toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), { - duration: 12000, - position: 'bottom-center', - }); + dismissAfter( + toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), { + duration: Infinity, + position: 'bottom-center', + }), + 12000, + ); loadAttendees(eventId, { silent: true }); } finally { setBusyId((current) => (current === opts.busyKey ? null : current));