Close door-screen toasts on our own timer so they dismiss on touchscreens. #37

Merged
Michilis merged 1 commits from dev into main 2026-09-19 18:16:41 +00:00
2 changed files with 46 additions and 12 deletions
@@ -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 <Toaster> 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;
}
+29 -12
View File
@@ -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() {
</button>
</div>
),
{ 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));