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>
216 lines
8.2 KiB
JavaScript
216 lines
8.2 KiB
JavaScript
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import { useFocusTrap } from '../../lib/use-focus-trap';
|
|
|
|
export default function ScannerDisambiguation({
|
|
disambiguation,
|
|
submittingReview,
|
|
onPick,
|
|
onNotInCatalog,
|
|
onCancel,
|
|
}) {
|
|
const sheetRef = useFocusTrap(Boolean(disambiguation));
|
|
const backdropRef = useRef(null);
|
|
const scrollContainerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!disambiguation) return undefined;
|
|
|
|
const backdrop = backdropRef.current;
|
|
const sheet = sheetRef.current;
|
|
if (!backdrop || !sheet) return undefined;
|
|
|
|
requestAnimationFrame(() => {
|
|
backdrop.dataset.visible = 'true';
|
|
sheet.dataset.visible = 'true';
|
|
});
|
|
|
|
const handler = (e) => {
|
|
if (e.key === 'Escape') onCancel();
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [disambiguation, onCancel, sheetRef]);
|
|
|
|
const handleBackdropClick = useCallback(
|
|
(e) => {
|
|
if (e.target === e.currentTarget) onCancel();
|
|
},
|
|
[onCancel]
|
|
);
|
|
|
|
if (!disambiguation) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-50 disambiguation-backdrop"
|
|
onClick={handleBackdropClick}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="disambiguation-sheet-title"
|
|
>
|
|
{/* Bottom sheet */}
|
|
<div
|
|
ref={sheetRef}
|
|
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden disambiguation-sheet"
|
|
>
|
|
{/* Drag handle */}
|
|
<div className="flex justify-center pt-3 pb-2">
|
|
<div
|
|
className="w-10 h-1 rounded-full"
|
|
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
|
|
<div className="px-4 sm:px-6 pb-2">
|
|
<h3
|
|
id="disambiguation-sheet-title"
|
|
className="text-lg font-semibold"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
Which card is this?
|
|
</h3>
|
|
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
|
|
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
|
|
</p>
|
|
|
|
{disambiguation.visionHint && (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mt-2"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
color: 'var(--accent-ember)',
|
|
}}
|
|
>
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
|
<path fillRule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
|
|
</svg>
|
|
Vision detected: {disambiguation.visionHint}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Horizontal candidate scroll */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex gap-3 overflow-x-auto px-4 sm:px-6 py-3 snap-x snap-mandatory"
|
|
style={{
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
WebkitOverflowScrolling: 'touch',
|
|
}}
|
|
role="listbox"
|
|
aria-label="Card candidates"
|
|
>
|
|
{disambiguation.candidates.map((candidate) => (
|
|
<button
|
|
type="button"
|
|
key={candidate.id}
|
|
onClick={() => onPick(candidate)}
|
|
className="flex-shrink-0 w-32 rounded-xl overflow-hidden text-left transition-all duration-150 snap-start focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
border: '2px solid transparent',
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
'--tw-ring-offset-color': 'transparent',
|
|
minHeight: '44px',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
|
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent-ember)'; }}
|
|
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
|
role="option"
|
|
aria-selected={false}
|
|
aria-label={`Select ${candidate.name}${candidate.set_name ? `, ${candidate.set_name}` : ''}`}
|
|
>
|
|
{candidate.image_url ? (
|
|
<img
|
|
src={candidate.image_url}
|
|
alt=""
|
|
className="w-full aspect-[5/7] object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-full aspect-[5/7] flex items-center justify-center"
|
|
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
|
>
|
|
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
<div className="p-2">
|
|
<div className="font-medium text-xs truncate" style={{ color: 'var(--text-primary)' }}>
|
|
{candidate.name}
|
|
</div>
|
|
<div className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
|
|
{[candidate.set_name, candidate.card_number].filter(Boolean).join(' · ')}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="px-4 sm:px-6 pb-4" style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}>
|
|
<button
|
|
type="button"
|
|
onClick={onNotInCatalog}
|
|
disabled={submittingReview}
|
|
className="w-full py-3 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
color: 'var(--text-primary)',
|
|
border: '1px solid var(--border)',
|
|
minHeight: '44px',
|
|
}}
|
|
>
|
|
{submittingReview ? 'Submitting...' : 'Not listed \u2014 send for review'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="w-full py-3 mt-2 rounded-xl text-sm font-medium transition-opacity duration-150 hover:opacity-70"
|
|
style={{
|
|
color: 'var(--text-secondary)',
|
|
background: 'transparent',
|
|
minHeight: '44px',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
.disambiguation-backdrop {
|
|
background: transparent;
|
|
backdrop-filter: none;
|
|
-webkit-backdrop-filter: none;
|
|
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
|
|
-webkit-backdrop-filter 300ms ease-out;
|
|
}
|
|
.disambiguation-backdrop[data-visible='true'] {
|
|
background: var(--modal-scrim);
|
|
backdrop-filter: blur(var(--glass-blur-high));
|
|
-webkit-backdrop-filter: blur(var(--glass-blur-high));
|
|
}
|
|
.disambiguation-sheet {
|
|
max-height: 60vh;
|
|
transform: translateY(100%);
|
|
transition: transform 300ms ease-out;
|
|
}
|
|
.disambiguation-sheet[data-visible='true'] {
|
|
transform: translateY(0);
|
|
}
|
|
div[role='listbox']::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|