Close door-screen toasts on our own timer so they dismiss on touchscreens. #37
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user