deckhearth/pages/scanner.js
Randall Stillwell cf9fea0726 feat(scanner): rebuild as mobile-first three-phase flow
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>
2026-06-13 08:42:58 -05:00

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>
);
}