Replace the desktop-first, everything-at-once scanner layout with a phased mobile-optimized experience: Setup → Scanning → Review. Phase 1 (Setup): destination picker, game filter, deck mode toggle, scan history (last 5 sessions). Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound feedback on card detection, torch/flash toggle, count pill, bottom-sheet disambiguation (replaces full-screen modal). Phase 3 (Review): card list with inline condition/foil/qty edits, batch confirm, 30-second undo, deck progress indicator. New features: - Deck mode (progress toward 40/60/99 card target) - Scan history (persisted to localStorage) - Sound feedback (Web Audio oscillator, configurable) - Offline queue (localStorage persistence + auto-retry on reconnect) - Camera flash/torch toggle - Batch ownership API (replaces N+1 per-card fetches) - Visibility pause (detection loop stops when tab is backgrounded) Convoy: scanner-rebuild Co-authored-by: Cursor <cursoragent@cursor.com>
58 lines
1.8 KiB
JavaScript
58 lines
1.8 KiB
JavaScript
const ICON_MAP = {
|
||
success: { glyph: '✓', color: 'var(--color-success, #10B981)' },
|
||
error: { glyph: '✗', color: 'var(--color-error, #EF4444)' },
|
||
info: { glyph: 'ℹ', color: 'var(--color-info, #3B82F6)' },
|
||
};
|
||
|
||
/**
|
||
* Floating toast overlay for the camera viewport.
|
||
*
|
||
* Always rendered in the DOM so CSS transitions work on enter and exit.
|
||
* The parent controls visibility via the `visible` prop and handles the
|
||
* auto-dismiss timer.
|
||
*/
|
||
export default function ScannerToast({ message, visible, type = 'success' }) {
|
||
const { glyph, color } = ICON_MAP[type] || ICON_MAP.success;
|
||
|
||
return (
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
className="absolute left-1/2 z-20 pointer-events-none"
|
||
style={{
|
||
bottom: '33%',
|
||
transform: `translateX(-50%) translateY(${visible ? '0' : '12px'})`,
|
||
opacity: visible ? 1 : 0,
|
||
transition: 'opacity 200ms ease, transform 200ms ease',
|
||
visibility: visible ? 'visible' : 'hidden',
|
||
transitionProperty: 'opacity, transform, visibility',
|
||
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center gap-2 rounded-full px-4 py-3 shadow-lg pointer-events-auto"
|
||
style={{
|
||
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.9)',
|
||
backdropFilter: 'blur(12px)',
|
||
WebkitBackdropFilter: 'blur(12px)',
|
||
border: '1px solid var(--border)',
|
||
maxWidth: 280,
|
||
}}
|
||
>
|
||
<span
|
||
className="text-sm font-bold flex-shrink-0"
|
||
style={{ color }}
|
||
aria-hidden="true"
|
||
>
|
||
{glyph}
|
||
</span>
|
||
<span
|
||
className="text-sm font-medium truncate"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
>
|
||
{message}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|