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>
121 lines
3.5 KiB
JavaScript
121 lines
3.5 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Layout from '../components/Layout';
|
|
import ScannerSetup from '../components/scanner/ScannerSetup';
|
|
import ScannerCamera from '../components/scanner/ScannerCamera';
|
|
import ScannerReview from '../components/scanner/ScannerReview';
|
|
import { useAuth } from '../lib/use-auth';
|
|
import { useScannerSession } from '../lib/use-scanner-session.js';
|
|
import { useScannerQueue } from '../lib/use-scanner-queue.js';
|
|
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
|
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
|
|
|
export default function Scanner() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const router = useRouter();
|
|
const [phase, setPhase] = useState('setup');
|
|
const [deckMode, setDeckMode] = useState(null);
|
|
|
|
const {
|
|
sessionDestination,
|
|
setSessionDestination,
|
|
gameFilter,
|
|
setGameFilter,
|
|
scanDefaults,
|
|
setScanDefaults,
|
|
scanHistory,
|
|
saveScanToHistory,
|
|
} = useScannerSession();
|
|
|
|
const queue = useScannerQueue({ user, sessionDestination, scanDefaults, deckMode });
|
|
|
|
const onVerifyCardRef = useRef(null);
|
|
const verificationPausedRef = useRef(false);
|
|
|
|
const camera = useCameraScanner({
|
|
onError: (msg) => console.error('Camera error:', msg),
|
|
onVerifyCard: (cardTracker) => onVerifyCardRef.current?.(cardTracker),
|
|
verificationPausedRef,
|
|
});
|
|
|
|
const identification = useScannerIdentification({
|
|
onCardScanned: queue.handleCardScanned,
|
|
onError: (msg) => console.error('Identification error:', msg),
|
|
videoRef: camera.videoRef,
|
|
canvasRef: camera.canvasRef,
|
|
onVerifyCardRef,
|
|
verificationPausedRef,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) {
|
|
router.push('/login');
|
|
}
|
|
}, [authLoading, user, router]);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<Layout user={null}>
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div
|
|
className="animate-spin rounded-full h-12 w-12 border-b-2"
|
|
style={{ borderColor: 'var(--text-accent)' }}
|
|
/>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Layout user={user}>
|
|
{phase === 'setup' && (
|
|
<ScannerSetup
|
|
sessionDestination={sessionDestination}
|
|
onDestinationChange={setSessionDestination}
|
|
gameFilter={gameFilter}
|
|
onGameFilterChange={setGameFilter}
|
|
collections={queue.collections}
|
|
decks={queue.decks}
|
|
deckMode={deckMode}
|
|
onDeckModeChange={setDeckMode}
|
|
onStartScanning={() => setPhase('scanning')}
|
|
scanHistory={scanHistory}
|
|
/>
|
|
)}
|
|
|
|
{phase === 'scanning' && (
|
|
<ScannerCamera
|
|
queue={queue}
|
|
camera={camera}
|
|
identification={identification}
|
|
deckMode={deckMode}
|
|
onReview={() => setPhase('review')}
|
|
onStopSession={() => {
|
|
camera.stopCamera();
|
|
setPhase('review');
|
|
}}
|
|
sessionDestination={sessionDestination}
|
|
/>
|
|
)}
|
|
|
|
{phase === 'review' && (
|
|
<ScannerReview
|
|
queue={queue}
|
|
collections={queue.collections}
|
|
decks={queue.decks}
|
|
deckMode={deckMode}
|
|
onContinueScanning={() => setPhase('scanning')}
|
|
onNewSession={() => {
|
|
queue.clearScannedCards();
|
|
setPhase('setup');
|
|
}}
|
|
sessionDestination={sessionDestination}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|