diff --git a/.convoys/scanner-rebuild.md b/.convoys/scanner-rebuild.md
new file mode 100644
index 0000000..75392a8
--- /dev/null
+++ b/.convoys/scanner-rebuild.md
@@ -0,0 +1,63 @@
+# Convoy: scanner-rebuild
+
+**Status:** In Progress (auto-approved — overnight build)
+**Owner:** Agent
+**Created:** 2026-06-13
+
+## Goal
+
+Rebuild the card scanner from a desktop-first, configuration-heavy, everything-at-once layout into a mobile-first, three-phase flow optimized for rapid multi-card scanning.
+
+## Architecture Decisions
+
+### D1 — Three-phase flow
+
+The scanner page becomes a state machine with three phases:
+- **Setup** — choose destination + game (one-time per session)
+- **Scanning** — full-screen camera with success toasts + count pill
+- **Review** — card list with per-card edits + batch confirm
+
+### D2 — Mobile-first camera
+
+Camera fills the viewport during scanning phase. No side panels, no scrolling to see results. Success feedback via overlay toasts + haptic vibration.
+
+### D3 — Auto-everything during scan
+
+- Cards auto-route to chosen destination immediately
+- Condition defaults NM, foil auto-detected from vision response
+- Game auto-detected from vision (no pre-filter needed)
+- Duplicates auto-increment quantity
+
+### D4 — Deck Mode
+
+Toggle in setup phase. Shows progress toward format-aware deck size (60/99/40). Auto-suggests stop when target reached.
+
+### D5 — Disambiguation as bottom sheet
+
+Replace full-screen modal with a slide-up bottom sheet. One tap to pick, then immediately resume scanning.
+
+### D6 — New features
+
+- **Scan history** — last 5 sessions persisted in localStorage
+- **Sound feedback** — subtle blip on successful scan (configurable)
+- **Offline queue** — if network drops, queue identification calls and retry
+- **Camera flash toggle** — torch mode for foil detection in dim lighting
+
+## File ownership
+
+| Workstream | Files | Agent |
+|---|---|---|
+| Core orchestrator | `pages/scanner.js`, `lib/use-scanner-session.js` | A |
+| Camera phase | `components/scanner/ScannerCamera.js`, `ScannerToast.js`, `ScannerCountPill.js`, `lib/use-camera-scanner.js`, `lib/use-scanner-sound.js`, `lib/use-scanner-flash.js` | B |
+| Setup + Review | `components/scanner/ScannerSetup.js`, `ScannerReview.js`, `ScannerDisambiguation.js`, `DeckModeIndicator.js` | C |
+| Queue + API | `lib/use-scanner-queue.js`, `lib/use-scanner-offline.js`, `pages/api/cards/batch-ownership.js` | D |
+
+## Risks
+
+- R1: Parallel agents may produce interface mismatches → mitigated by specifying contracts in each agent's prompt
+- R2: Existing hooks (`use-scanner-identification.js`, `scanner-card-identify.js`, `scanner-card-detection.js`) are not being rewritten — the rebuild layers on top of them
+- R3: Visual regression risk — existing smoke tests may break → handled in integration pass
+
+## Human gates bypassed
+
+Per user instruction (overnight build, 2026-06-13), all architect/reviewer gates are bypassed for this convoy. The user will review the final output in the morning.
diff --git a/components/scanner/DeckModeIndicator.js b/components/scanner/DeckModeIndicator.js
new file mode 100644
index 0000000..34b1921
--- /dev/null
+++ b/components/scanner/DeckModeIndicator.js
@@ -0,0 +1,51 @@
+export default function DeckModeIndicator({ current, target, game }) {
+ const progress = target > 0 ? Math.min(current / target, 1) : 0;
+ const isComplete = current >= target;
+
+ return (
+
+ {/* Progress track */}
+
+
+ {/* Labels */}
+
+
+ {isComplete ? 'Deck complete!' : `${current}/${target} cards`}
+
+
+ {game}
+
+
+
+ );
+}
diff --git a/components/scanner/ReviewCardItem.js b/components/scanner/ReviewCardItem.js
new file mode 100644
index 0000000..b780f58
--- /dev/null
+++ b/components/scanner/ReviewCardItem.js
@@ -0,0 +1,246 @@
+/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
+import { formatProcessedDestination } from '../../lib/collection-vocabulary.js';
+
+const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
+
+export default function ReviewCardItem({
+ card,
+ collections,
+ decks,
+ onIncrement,
+ onDecrement,
+ onUpdateMetadata,
+ onRemove,
+ onUndo,
+ isAdding = false,
+ sessionDestination,
+}) {
+ const destinationLabel = card.processedAction
+ ? formatProcessedDestination(card.processedAction)
+ : sessionDestination?.label ?? 'My Collection';
+
+ return (
+
+
+ {/* Thumbnail */}
+
+ {card.image_url ? (
+

+ ) : (
+
+ )}
+
+
+ {/* Card info + controls */}
+
+
+
+
+ {card.processed ? (
+
+ ) : (
+
+ )}
+
+ {card.name}
+
+
+
+ {[card.set, card.cardNumber].filter(Boolean).join(' · ')}
+
+
+
+ {/* Remove button (unprocessed only) */}
+ {!card.processed && (
+
+ )}
+
+
+ {card.processed ? (
+
+
+ Added to {destinationLabel}
+
+ {onUndo && (
+
+ )}
+
+ ) : (
+ <>
+ {/* Metadata summary line */}
+
+ {card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
+
+
+ {/* Inline edit controls */}
+
+ {/* Condition */}
+
+
+ {/* Foil toggle */}
+
+
+ {/* Quantity ± */}
+
+
+
+ {card.quantity || 1}
+
+
+
+
+
+ {/* Destination override */}
+ {(collections.length > 0 || decks.length > 0) && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/components/scanner/ScannerCamera.js b/components/scanner/ScannerCamera.js
new file mode 100644
index 0000000..0d9e4d9
--- /dev/null
+++ b/components/scanner/ScannerCamera.js
@@ -0,0 +1,294 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import ScannerToast from './ScannerToast.js';
+import ScannerCountPill from './ScannerCountPill.js';
+import DeckModeIndicator from './DeckModeIndicator.js';
+import ScannerDisambiguation from './ScannerDisambiguation.js';
+import { useScannerSound } from '../../lib/use-scanner-sound.js';
+import { useScannerFlash } from '../../lib/use-scanner-flash.js';
+
+const TOAST_DURATION_MS = 2500;
+
+function overlayBorderColor(status) {
+ if (status === 'scanned') return 'var(--color-info, #3B82F6)';
+ return 'var(--color-success, #10B981)';
+}
+
+function overlayGlowColor(status) {
+ if (status === 'scanned') return 'rgba(59, 130, 246, 0.31)';
+ return 'rgba(16, 185, 129, 0.31)';
+}
+
+export default function ScannerCamera({
+ queue,
+ camera,
+ identification,
+ deckMode,
+ onReview,
+ onStopSession,
+ sessionDestination,
+}) {
+ const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
+ const toastTimerRef = useRef(null);
+ const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
+
+ const {
+ videoRef,
+ canvasRef,
+ detectionCanvasRef,
+ isStreaming,
+ isDetecting,
+ trackedCards,
+ videoMetrics,
+ startCamera,
+ stopCamera,
+ streamRef,
+ } = camera;
+
+ const sound = useScannerSound();
+ // Requires useCameraScanner to expose streamRef — gracefully degrades to flash-unsupported if absent
+ const flash = useScannerFlash(streamRef);
+
+ // Auto-start camera on mount
+ useEffect(() => {
+ startCamera();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
+ }, []);
+
+ // React to newly scanned cards
+ const currentCount = queue.scannedCards?.length ?? 0;
+ useEffect(() => {
+ if (currentCount > prevCountRef.current) {
+ const newest = queue.scannedCards[0];
+ const cardName = newest?.name || newest?.card?.name || 'Card scanned';
+
+ navigator.vibrate?.(50);
+ sound.playSuccess();
+
+ clearTimeout(toastTimerRef.current);
+ setToast({ message: cardName, visible: true, type: 'success' });
+ toastTimerRef.current = setTimeout(() => {
+ setToast((t) => ({ ...t, visible: false }));
+ }, TOAST_DURATION_MS);
+ }
+ prevCountRef.current = currentCount;
+ }, [currentCount]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only reacts to count
+
+ useEffect(() => {
+ return () => clearTimeout(toastTimerRef.current);
+ }, []);
+
+ const handleStop = useCallback(() => {
+ stopCamera();
+ onStopSession?.();
+ }, [stopCamera, onStopSession]);
+
+ const foundCards = trackedCards.filter(
+ (c) => c.status === 'confirmed' || c.status === 'scanned'
+ );
+
+ const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
+
+ return (
+
+ {/* Camera viewport */}
+
+ {/* Video */}
+
+
+ {/* Bounding box overlays */}
+ {isStreaming &&
+ hasMetrics &&
+ foundCards.map((card) => (
+
+
+ {card.status === 'scanned' ? 'Scanned' : 'Found'}
+
+
+ ))}
+
+ {/* LIVE indicator */}
+ {isStreaming && (
+
+ )}
+
+ {/* Toast */}
+
+
+ {/* Bottom controls */}
+ {isStreaming && (
+
+ {/* Sound toggle */}
+
+
+ {/* Stop button */}
+
+
+ {/* Flash toggle */}
+ {flash.flashSupported && (
+
+ )}
+
+ )}
+
+ {/* Loading state (before stream starts) */}
+ {!isStreaming && (
+
+
+
+
+ Starting camera…
+
+
+
+ )}
+
+
+ {/* Hidden canvases for detection */}
+
+
+
+ {/* Count pill */}
+
+
+ {/* Deck mode progress */}
+ {deckMode && (
+
+ )}
+
+ {/* Disambiguation overlay */}
+ {identification.disambiguation && (
+
+ )}
+
+ );
+}
diff --git a/components/scanner/ScannerCountPill.js b/components/scanner/ScannerCountPill.js
new file mode 100644
index 0000000..11a259c
--- /dev/null
+++ b/components/scanner/ScannerCountPill.js
@@ -0,0 +1,32 @@
+export default function ScannerCountPill({ count, onReview }) {
+ if (count <= 0) return null;
+
+ return (
+
+
+ {count} {count === 1 ? 'card' : 'cards'} scanned
+
+
+
+
+ );
+}
diff --git a/components/scanner/ScannerDisambiguation.js b/components/scanner/ScannerDisambiguation.js
new file mode 100644
index 0000000..cb27a07
--- /dev/null
+++ b/components/scanner/ScannerDisambiguation.js
@@ -0,0 +1,216 @@
+/* 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 (
+
+ {/* Bottom sheet */}
+
+ {/* Drag handle */}
+
+
+
+
+ Which card is this?
+
+
+ {disambiguation.message || 'Multiple matches found. Select the correct printing.'}
+
+
+ {disambiguation.visionHint && (
+
+
+ Vision detected: {disambiguation.visionHint}
+
+ )}
+
+
+ {/* Horizontal candidate scroll */}
+
+ {disambiguation.candidates.map((candidate) => (
+
+ ))}
+
+
+ {/* Action buttons */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/scanner/ScannerReview.js b/components/scanner/ScannerReview.js
new file mode 100644
index 0000000..911c027
--- /dev/null
+++ b/components/scanner/ScannerReview.js
@@ -0,0 +1,192 @@
+import { useMemo } from 'react';
+import { Button } from '../ui';
+import ReviewCardItem from './ReviewCardItem';
+
+export default function ScannerReview({
+ queue,
+ collections,
+ decks,
+ deckMode,
+ onContinueScanning,
+ onNewSession,
+ sessionDestination,
+}) {
+ const { scannedCards } = queue;
+
+ const unprocessedCards = useMemo(
+ () => scannedCards.filter((c) => !c.processed),
+ [scannedCards]
+ );
+
+ const processedCards = useMemo(
+ () => scannedCards.filter((c) => c.processed),
+ [scannedCards]
+ );
+
+ const deckProgress = deckMode
+ ? { current: scannedCards.length, target: deckMode.targetSize }
+ : null;
+
+ const handleConfirmAll = () => {
+ if (unprocessedCards.length === 0) return;
+ const unprocessedIds = unprocessedCards.map((c) => c.id);
+ unprocessedIds.forEach((id) => queue.toggleCardSelection(id));
+ setTimeout(() => queue.handleBulkAction('owned'), 0);
+ };
+
+ if (scannedCards.length === 0) {
+ return (
+
+
+
+ No cards to review
+
+
+ Scan some cards first, then come back to review and confirm them.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Review{' '}
+
+ ({scannedCards.length} {scannedCards.length === 1 ? 'card' : 'cards'})
+
+
+
+
+
+ {/* Deck progress (if applicable) */}
+ {deckProgress && (
+
+
+
+ Deck progress
+
+
+ {deckProgress.current} / {deckProgress.target}
+
+
+
+
+ )}
+
+ {/* Scrollable card list */}
+
+ {/* Unprocessed cards first */}
+ {unprocessedCards.length > 0 && (
+
+
+ Pending ({unprocessedCards.length})
+
+ {unprocessedCards.map((card) => (
+ queue.incrementCardQuantity(card.id)}
+ onDecrement={() => queue.decrementCardQuantity(card.id)}
+ onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
+ onRemove={() => queue.removeScannedCard(card.id)}
+ isAdding={false}
+ sessionDestination={sessionDestination}
+ />
+ ))}
+
+ )}
+
+ {/* Processed cards */}
+ {processedCards.length > 0 && (
+
0 ? 'mt-4' : ''}>
+
+ Confirmed ({processedCards.length})
+
+ {processedCards.map((card) => (
+ {}}
+ onDecrement={() => {}}
+ onUpdateMetadata={() => {}}
+ onRemove={() => {}}
+ onUndo={() => queue.updateCardMetadata(card.id, { processed: false, processedAction: null })}
+ isAdding={false}
+ sessionDestination={sessionDestination}
+ />
+ ))}
+
+ )}
+
+
+ {/* Sticky bottom action bar */}
+
+
+
+
+
+ );
+}
diff --git a/components/scanner/ScannerSetup.js b/components/scanner/ScannerSetup.js
new file mode 100644
index 0000000..aef0110
--- /dev/null
+++ b/components/scanner/ScannerSetup.js
@@ -0,0 +1,379 @@
+import { useState, useId } from 'react';
+import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
+import { Button } from '../ui';
+
+const GAMES = [
+ { value: 'All', label: 'Auto-detect' },
+ { value: 'mtg', label: 'Magic' },
+ { value: 'pokemon', label: 'Pokémon' },
+ { value: 'lorcana', label: 'Lorcana' },
+];
+
+const DECK_SIZES = [
+ { value: 40, label: '40' },
+ { value: 60, label: '60' },
+ { value: 99, label: '99' },
+];
+
+const DESTINATION_TYPES = [
+ { value: 'owned', label: VOCAB.MY_COLLECTION },
+ { value: 'collection', label: VOCAB.LIST },
+ { value: 'deck', label: 'Deck' },
+];
+
+function ToggleGroup({ options, value, onChange, name, className = '' }) {
+ return (
+
+ {options.map((opt) => {
+ const selected = opt.value === value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function ScanHistoryList({ history }) {
+ const [expanded, setExpanded] = useState(false);
+ const panelId = useId();
+
+ if (!history || history.length === 0) return null;
+
+ const formatDate = (iso) => {
+ try {
+ return new Date(iso).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ } catch {
+ return 'Unknown date';
+ }
+ };
+
+ const destinationLabel = (dest) => {
+ if (!dest) return 'Unknown';
+ if (dest.type === 'owned') return VOCAB.MY_COLLECTION;
+ return dest.label || dest.type;
+ };
+
+ return (
+
+
+
+ {expanded && (
+
+ )}
+
+ );
+}
+
+export default function ScannerSetup({
+ sessionDestination,
+ onDestinationChange,
+ gameFilter,
+ onGameFilterChange,
+ collections,
+ decks,
+ deckMode,
+ onDeckModeChange,
+ onStartScanning,
+ scanHistory,
+}) {
+ const destSelectId = useId();
+ const gameGroupId = useId();
+ const deckModeId = useId();
+ const deckSizeId = useId();
+
+ const destinationType = sessionDestination?.type ?? 'owned';
+
+ const handleDestinationTypeChange = (type) => {
+ if (type === 'owned') {
+ onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION });
+ } else if (type === 'collection') {
+ const first = collections?.[0];
+ onDestinationChange(
+ first
+ ? { type: 'collection', id: first.id, label: collectionDisplayName(first) }
+ : { type: 'collection', id: null, label: VOCAB.LIST }
+ );
+ } else if (type === 'deck') {
+ const first = decks?.[0];
+ onDestinationChange(
+ first
+ ? { type: 'deck', id: first.id, label: first.name }
+ : { type: 'deck', id: null, label: 'Deck' }
+ );
+ }
+ };
+
+ const handleListSelect = (e) => {
+ const id = parseInt(e.target.value, 10);
+ const found = collections.find((c) => c.id === id);
+ if (found) {
+ onDestinationChange({
+ type: 'collection',
+ id: found.id,
+ label: collectionDisplayName(found),
+ });
+ }
+ };
+
+ const handleDeckSelect = (e) => {
+ const id = parseInt(e.target.value, 10);
+ const found = decks.find((d) => d.id === id);
+ if (found) {
+ onDestinationChange({ type: 'deck', id: found.id, label: found.name });
+ }
+ };
+
+ const handleDeckModeToggle = () => {
+ if (deckMode) {
+ onDeckModeChange(null);
+ } else {
+ onDeckModeChange({ game: gameFilter, targetSize: 60 });
+ }
+ };
+
+ const handleDeckSizeChange = (size) => {
+ onDeckModeChange({ ...deckMode, targetSize: size });
+ };
+
+ const selectStyle = {
+ backgroundColor: 'var(--bg-tertiary)',
+ color: 'var(--text-primary)',
+ borderColor: 'var(--border)',
+ };
+
+ return (
+
+
+
+
+ {/* Destination picker */}
+
+
+ {/* Game filter */}
+
+
+ {/* Deck mode toggle */}
+
+
+
+
+
+ {deckMode && (
+
+ )}
+
+ {/* Start scanning CTA */}
+
+
+ {/* Scan history */}
+
+
+
+ );
+}
diff --git a/components/scanner/ScannerToast.js b/components/scanner/ScannerToast.js
new file mode 100644
index 0000000..d5bfff2
--- /dev/null
+++ b/components/scanner/ScannerToast.js
@@ -0,0 +1,58 @@
+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 (
+
+
+
+ {glyph}
+
+
+ {message}
+
+
+
+ );
+}
diff --git a/lib/scanner-route-api.js b/lib/scanner-route-api.js
index 214f40c..0888352 100644
--- a/lib/scanner-route-api.js
+++ b/lib/scanner-route-api.js
@@ -96,6 +96,21 @@ export async function createScannerCollection(name) {
return response.json();
}
+export async function fetchBatchOwnership(cardIds) {
+ const response = await fetch('/api/cards/batch-ownership', {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({ cardIds }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch ownership data');
+ }
+
+ const data = await response.json();
+ return data.ownership;
+}
+
export async function routeScannedCardToDestination(cardData, destination) {
if (!destination || !cardData.databaseId) return false;
diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js
index eec15a7..49c8d6f 100644
--- a/lib/use-camera-scanner.js
+++ b/lib/use-camera-scanner.js
@@ -78,15 +78,16 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
const startDetection = () => {
if (detectionIntervalRef.current || !isStreamingRef.current) return;
- console.log('🎯 Starting continuous card detection...');
setIsDetecting(true);
detectionIntervalRef.current = setInterval(() => {
+ if (document.hidden) return;
const shapes = detectCardShapes();
updateTrackedCards(shapes);
}, SHAPE_DETECTION_INTERVAL_MS);
trackingIntervalRef.current = setInterval(() => {
+ if (document.hidden) return;
if (verificationPausedRef?.current) return;
const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current);
@@ -216,5 +217,6 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
videoMetrics,
startCamera,
stopCamera,
+ streamRef,
};
}
diff --git a/lib/use-scanner-flash.js b/lib/use-scanner-flash.js
new file mode 100644
index 0000000..d4f5aa6
--- /dev/null
+++ b/lib/use-scanner-flash.js
@@ -0,0 +1,75 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * Camera torch / flash control.
+ *
+ * @param {React.MutableRefObject} streamRef
+ * A ref holding the active MediaStream from the camera hook.
+ */
+export function useScannerFlash(streamRef) {
+ const [flashOn, setFlashOn] = useState(false);
+ const [flashSupported, setFlashSupported] = useState(false);
+
+ const streamIdentityRef = useRef(null);
+
+ const getVideoTrack = useCallback(() => {
+ const stream = streamRef?.current;
+ if (!stream) return null;
+ const tracks = stream.getVideoTracks();
+ return tracks.length > 0 ? tracks[0] : null;
+ }, [streamRef]);
+
+ const probeFlashSupport = useCallback(() => {
+ const track = getVideoTrack();
+ if (!track) {
+ setFlashSupported(false);
+ return;
+ }
+
+ const capabilities = track.getCapabilities?.();
+ setFlashSupported(Boolean(capabilities?.torch));
+
+ const handleEnded = () => {
+ setFlashOn(false);
+ setFlashSupported(false);
+ };
+ track.addEventListener('ended', handleEnded);
+
+ return () => {
+ track.removeEventListener('ended', handleEnded);
+ };
+ }, [getVideoTrack]);
+
+ useEffect(() => {
+ const check = () => {
+ const currentStream = streamRef?.current ?? null;
+ if (currentStream !== streamIdentityRef.current) {
+ streamIdentityRef.current = currentStream;
+ probeFlashSupport();
+ }
+ };
+
+ const initialTimer = setTimeout(check, 0);
+ const interval = setInterval(check, 500);
+ return () => {
+ clearTimeout(initialTimer);
+ clearInterval(interval);
+ };
+ }, [probeFlashSupport, streamRef]);
+
+ const toggleFlash = useCallback(async () => {
+ if (!flashSupported) return;
+ const track = getVideoTrack();
+ if (!track) return;
+
+ const next = !flashOn;
+ try {
+ await track.applyConstraints({ advanced: [{ torch: next }] });
+ setFlashOn(next);
+ } catch (err) {
+ console.warn('Torch toggle failed:', err);
+ }
+ }, [flashSupported, flashOn, getVideoTrack]);
+
+ return { flashSupported, flashOn, toggleFlash };
+}
diff --git a/lib/use-scanner-offline.js b/lib/use-scanner-offline.js
new file mode 100644
index 0000000..d2ff152
--- /dev/null
+++ b/lib/use-scanner-offline.js
@@ -0,0 +1,135 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import {
+ addScannedCardToOwned,
+ addScannedCardToCollection,
+ addScannedCardToDeck,
+} from './scanner-route-api.js';
+
+const OFFLINE_QUEUE_KEY = 'deckhearth:offline-scan-queue';
+const MAX_RETRIES = 3;
+
+function loadQueue() {
+ if (typeof window === 'undefined') return [];
+ try {
+ return JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY)) || [];
+ } catch {
+ return [];
+ }
+}
+
+function persistQueue(queue) {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
+}
+
+async function executeAction(action, payload) {
+ switch (action) {
+ case 'addToOwned':
+ return addScannedCardToOwned(payload);
+ case 'addToCollection':
+ return addScannedCardToCollection(payload, payload.collectionId);
+ case 'addToDeck':
+ return addScannedCardToDeck(payload, payload.deckId);
+ default:
+ throw new Error(`Unknown offline action: ${action}`);
+ }
+}
+
+export function useScannerOffline() {
+ const [isOnline, setIsOnline] = useState(
+ () => typeof navigator !== 'undefined' ? navigator.onLine : true
+ );
+ const [queuedActions, setQueuedActions] = useState(loadQueue);
+ const processingRef = useRef(false);
+
+ const updateQueue = useCallback((updater) => {
+ setQueuedActions((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater;
+ persistQueue(next);
+ return next;
+ });
+ }, []);
+
+ const processQueue = useCallback(async () => {
+ if (processingRef.current) return;
+ processingRef.current = true;
+
+ try {
+ const current = loadQueue();
+ const pending = current.filter((item) => item.status === 'pending');
+
+ for (const item of pending) {
+ try {
+ await executeAction(item.action, item.payload);
+ updateQueue((prev) => prev.filter((q) => q.id !== item.id));
+ } catch {
+ updateQueue((prev) =>
+ prev.map((q) => {
+ if (q.id !== item.id) return q;
+ const retries = q.retries + 1;
+ return {
+ ...q,
+ retries,
+ status: retries >= MAX_RETRIES ? 'failed' : 'pending',
+ };
+ })
+ );
+ }
+ }
+ } finally {
+ processingRef.current = false;
+ }
+ }, [updateQueue]);
+
+ useEffect(() => {
+ const goOnline = () => {
+ setIsOnline(true);
+ processQueue();
+ };
+ const goOffline = () => setIsOnline(false);
+
+ window.addEventListener('online', goOnline);
+ window.addEventListener('offline', goOffline);
+ return () => {
+ window.removeEventListener('online', goOnline);
+ window.removeEventListener('offline', goOffline);
+ };
+ }, [processQueue]);
+
+ // Process any items left over from a previous session on mount
+ useEffect(() => {
+ if (isOnline && loadQueue().some((q) => q.status === 'pending')) {
+ processQueue();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const enqueue = useCallback(
+ async (action, payload) => {
+ const item = {
+ id: Date.now() + Math.random(),
+ action,
+ payload,
+ timestamp: Date.now(),
+ retries: 0,
+ status: 'pending',
+ };
+
+ if (isOnline) {
+ try {
+ await executeAction(action, payload);
+ return;
+ } catch {
+ // Network error while supposedly online — fall through to queue
+ }
+ }
+
+ updateQueue((prev) => [...prev, item]);
+ },
+ [isOnline, updateQueue]
+ );
+
+ const pendingCount = queuedActions.filter((q) => q.status === 'pending').length;
+
+ return { isOnline, queuedActions, enqueue, processQueue, pendingCount };
+}
diff --git a/lib/use-scanner-queue.js b/lib/use-scanner-queue.js
index 56d5f90..0c2ae18 100644
--- a/lib/use-scanner-queue.js
+++ b/lib/use-scanner-queue.js
@@ -1,19 +1,24 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import {
addScannedCardToCollection,
addScannedCardToDeck,
addScannedCardToOwned,
createScannerCollection,
+ fetchBatchOwnership,
fetchScannerCollections,
fetchScannerDecks,
routeScannedCardToDestination,
} from './scanner-route-api.js';
import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js';
+const UNDO_TTL_MS = 30_000;
+const UNDO_CLEANUP_INTERVAL_MS = 5_000;
+const OWNERSHIP_DEBOUNCE_MS = 500;
+
/**
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
*/
-export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
+export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
const [scannedCards, setScannedCards] = useState([]);
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
@@ -24,6 +29,8 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
const [autoRouteError, setAutoRouteError] = useState(null);
const [showCreateCollection, setShowCreateCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
+ const [undoStack, setUndoStack] = useState([]);
+ const [ownershipMap, setOwnershipMap] = useState({});
const addingInFlightRef = useRef(new Set());
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
@@ -68,14 +75,66 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
syncAddingState();
};
+ // --- Undo stack: clear expired entries every 5s ---
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const cutoff = Date.now() - UNDO_TTL_MS;
+ setUndoStack((prev) => {
+ const next = prev.filter((entry) => entry.timestamp > cutoff);
+ return next.length === prev.length ? prev : next;
+ });
+ }, UNDO_CLEANUP_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, []);
+
+ // --- Batch ownership: debounced fetch for new card IDs ---
+ const ownershipTimerRef = useRef(null);
+
+ useEffect(() => {
+ if (!user) return;
+
+ const dbIds = scannedCards
+ .filter((c) => c.databaseId)
+ .map((c) => c.databaseId);
+ const newIds = dbIds.filter((id) => !(id in ownershipMap));
+
+ if (newIds.length === 0) return;
+
+ clearTimeout(ownershipTimerRef.current);
+ ownershipTimerRef.current = setTimeout(async () => {
+ try {
+ const result = await fetchBatchOwnership(newIds);
+ setOwnershipMap((prev) => ({ ...prev, ...result }));
+ } catch (err) {
+ console.error('Batch ownership fetch failed:', err);
+ }
+ }, OWNERSHIP_DEBOUNCE_MS);
+
+ return () => clearTimeout(ownershipTimerRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- ownershipMap in deps would cause infinite loop
+ }, [scannedCards, user]);
+
+ // --- Deck mode completion: derived state, no effect needed ---
+ const deckComplete = Boolean(deckMode && scannedCards.length >= deckMode.targetSize);
+
const markCardAsProcessed = (cardId, action) => {
setScannedCards((prev) =>
prev.map((card) =>
card.id === cardId ? { ...card, processed: true, processedAction: action } : card
)
);
+ setUndoStack((prev) => [...prev, { cardId, action, timestamp: Date.now() }]);
};
+ const undoCardProcess = useCallback((cardId) => {
+ setScannedCards((prev) =>
+ prev.map((card) =>
+ card.id === cardId ? { ...card, processed: false, processedAction: null } : card
+ )
+ );
+ setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
+ }, []);
+
const handleCardScanned = async (cardData) => {
setAutoRouteError(null);
@@ -285,5 +344,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults }) {
removeScannedCard,
toggleCardSelection,
clearSelection,
+ undoCardProcess,
+ undoStack,
+ deckComplete,
+ ownershipMap,
};
}
diff --git a/lib/use-scanner-session.js b/lib/use-scanner-session.js
new file mode 100644
index 0000000..36226e7
--- /dev/null
+++ b/lib/use-scanner-session.js
@@ -0,0 +1,102 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { VOCAB } from './collection-vocabulary.js';
+
+const STORAGE_KEY = 'deckhearth:scanner-session';
+const HISTORY_KEY = 'deckhearth:scan-history';
+const MAX_HISTORY = 5;
+
+const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
+const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
+
+function readJson(key, fallback) {
+ if (typeof window === 'undefined') return fallback;
+ try {
+ const raw = localStorage.getItem(key);
+ return raw ? JSON.parse(raw) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function writeJson(key, value) {
+ if (typeof window === 'undefined') return;
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ /* quota exceeded — silently degrade */
+ }
+}
+
+export function useScannerSession() {
+ const [sessionDestination, setSessionDestination] = useState(() => {
+ const saved = readJson(STORAGE_KEY, {});
+ return saved.destination ?? DEFAULT_DESTINATION;
+ });
+ const [gameFilter, setGameFilter] = useState(() => {
+ const saved = readJson(STORAGE_KEY, {});
+ return saved.gameFilter ?? 'All';
+ });
+ const [scanDefaults, setScanDefaults] = useState(() => {
+ const saved = readJson(STORAGE_KEY, {});
+ return saved.scanDefaults ?? DEFAULT_SCAN_DEFAULTS;
+ });
+ const [scanHistory, setScanHistory] = useState(() => readJson(HISTORY_KEY, []));
+
+ const isInitialRender = useRef(true);
+
+ useEffect(() => {
+ if (isInitialRender.current) {
+ isInitialRender.current = false;
+ return;
+ }
+ writeJson(STORAGE_KEY, {
+ destination: sessionDestination,
+ gameFilter,
+ scanDefaults,
+ });
+ }, [sessionDestination, gameFilter, scanDefaults]);
+
+ const handleGameFilterChange = useCallback((nextFilter) => {
+ setGameFilter(nextFilter);
+ setSessionDestination((current) => {
+ if (!current || current.type === 'owned') return current;
+ return DEFAULT_DESTINATION;
+ });
+ }, []);
+
+ const handleDestinationChange = useCallback((next) => {
+ setSessionDestination(next);
+ }, []);
+
+ const handleScanDefaultsChange = useCallback((patch) => {
+ setScanDefaults((current) => ({ ...current, ...patch }));
+ }, []);
+
+ const saveScanToHistory = useCallback(
+ (cardCount) => {
+ setScanHistory((prev) => {
+ const entry = {
+ date: new Date().toISOString(),
+ cardCount,
+ destination: sessionDestination,
+ game: gameFilter,
+ };
+ const next = [entry, ...prev].slice(0, MAX_HISTORY);
+ writeJson(HISTORY_KEY, next);
+ return next;
+ });
+ },
+ [sessionDestination, gameFilter]
+ );
+
+ return {
+ sessionDestination,
+ setSessionDestination: handleDestinationChange,
+ gameFilter,
+ setGameFilter: handleGameFilterChange,
+ scanDefaults,
+ setScanDefaults: handleScanDefaultsChange,
+ scanHistory,
+ saveScanToHistory,
+ };
+}
diff --git a/lib/use-scanner-sound.js b/lib/use-scanner-sound.js
new file mode 100644
index 0000000..1e3b581
--- /dev/null
+++ b/lib/use-scanner-sound.js
@@ -0,0 +1,101 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const STORAGE_KEY = 'deckhearth:scanner-sound';
+
+function isMobileViewport() {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia('(max-width: 768px)').matches;
+}
+
+function readPersistedPreference() {
+ if (typeof window === 'undefined') return null;
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'true') return true;
+ if (stored === 'false') return false;
+ } catch {
+ // localStorage unavailable (e.g. private browsing on some browsers)
+ }
+ return null;
+}
+
+/**
+ * Scan sound feedback via the Web Audio API.
+ *
+ * - Persists the enabled/disabled preference to localStorage.
+ * - Defaults to ON on mobile, OFF on desktop.
+ * - AudioContext created lazily on first playback (browser autoplay policy).
+ */
+export function useScannerSound() {
+ const [enabled, setEnabledState] = useState(() => {
+ const persisted = readPersistedPreference();
+ return persisted ?? isMobileViewport();
+ });
+
+ const audioCtxRef = useRef(null);
+
+ const setEnabled = useCallback((value) => {
+ setEnabledState((prev) => {
+ const next = typeof value === 'function' ? value(prev) : value;
+ try {
+ localStorage.setItem(STORAGE_KEY, String(next));
+ } catch {
+ // ignore
+ }
+ return next;
+ });
+ }, []);
+
+ const getAudioContext = useCallback(() => {
+ if (!audioCtxRef.current) {
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
+ if (!AudioCtx) return null;
+ audioCtxRef.current = new AudioCtx();
+ }
+ if (audioCtxRef.current.state === 'suspended') {
+ audioCtxRef.current.resume().catch(() => {});
+ }
+ return audioCtxRef.current;
+ }, []);
+
+ const playTone = useCallback(
+ (frequency, durationMs) => {
+ if (!enabled) return;
+ const ctx = getAudioContext();
+ if (!ctx) return;
+
+ const oscillator = ctx.createOscillator();
+ const gain = ctx.createGain();
+
+ oscillator.type = 'sine';
+ oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
+
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(
+ 0.001,
+ ctx.currentTime + durationMs / 1000
+ );
+
+ oscillator.connect(gain);
+ gain.connect(ctx.destination);
+
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + durationMs / 1000);
+ },
+ [enabled, getAudioContext]
+ );
+
+ const playSuccess = useCallback(() => playTone(880, 50), [playTone]);
+ const playError = useCallback(() => playTone(440, 80), [playTone]);
+
+ useEffect(() => {
+ return () => {
+ if (audioCtxRef.current) {
+ audioCtxRef.current.close().catch(() => {});
+ audioCtxRef.current = null;
+ }
+ };
+ }, []);
+
+ return { enabled, setEnabled, playSuccess, playError };
+}
diff --git a/pages/api/cards/batch-ownership.js b/pages/api/cards/batch-ownership.js
new file mode 100644
index 0000000..6689713
--- /dev/null
+++ b/pages/api/cards/batch-ownership.js
@@ -0,0 +1,50 @@
+import { sql } from '@vercel/postgres';
+import { getUserFromRequest } from '../../../lib/permission-middleware';
+
+export default async function handler(req, res) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ try {
+ const user = await getUserFromRequest(req);
+ if (!user) {
+ return res.status(401).json({ error: 'Authentication required' });
+ }
+
+ const { cardIds } = req.body || {};
+
+ if (!Array.isArray(cardIds) || cardIds.length === 0) {
+ return res.status(400).json({ error: 'cardIds must be a non-empty array' });
+ }
+
+ if (cardIds.length > 100) {
+ return res.status(400).json({ error: 'cardIds cannot exceed 100 items' });
+ }
+
+ const valid = cardIds.every(
+ (id) => Number.isInteger(id) && id > 0
+ );
+ if (!valid) {
+ return res.status(400).json({ error: 'All cardIds must be positive integers' });
+ }
+
+ const result = await sql`
+ SELECT card_id, SUM(quantity)::int AS total_quantity
+ FROM user_cards
+ WHERE user_id = ${user.userId}
+ AND card_id = ANY(${cardIds})
+ GROUP BY card_id
+ `;
+
+ const ownership = {};
+ for (const row of result.rows) {
+ ownership[row.card_id] = row.total_quantity;
+ }
+
+ return res.status(200).json({ ownership });
+ } catch (error) {
+ console.error('[POST /api/cards/batch-ownership]', error);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+}
diff --git a/pages/scanner.js b/pages/scanner.js
index a2a95fa..00c9671 100644
--- a/pages/scanner.js
+++ b/pages/scanner.js
@@ -1,26 +1,51 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
-import ScannerPageView from '../components/ScannerPageView';
+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 {
- DEFAULT_SCANNER_DESTINATION,
- loadSavedScannerSession,
- saveScannerSession,
-} from '../lib/scanner-session.js';
+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 [showOCRSettings, setShowOCRSettings] = useState(false);
- const [sessionDestination, setSessionDestination] = useState(
- () => loadSavedScannerSession().destination
- );
- const [gameFilter, setGameFilter] = useState(() => loadSavedScannerSession().gameFilter);
- const [scanDefaults, setScanDefaults] = useState(() => loadSavedScannerSession().scanDefaults);
+ const [phase, setPhase] = useState('setup');
+ const [deckMode, setDeckMode] = useState(null);
- const queue = useScannerQueue({ user, sessionDestination, scanDefaults });
+ 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) {
@@ -28,51 +53,69 @@ export default function Scanner() {
}
}, [authLoading, user, router]);
- useEffect(() => {
- saveScannerSession({ destination: sessionDestination, gameFilter, scanDefaults });
- }, [sessionDestination, gameFilter, scanDefaults]);
-
- const handleGameFilterChange = (nextFilter) => {
- setGameFilter(nextFilter);
- setSessionDestination((current) => {
- if (!current || current.type === 'owned') return current;
- return DEFAULT_SCANNER_DESTINATION;
- });
- };
-
- const handleScannerError = (error) => {
- console.error('Scanner error:', error);
- };
-
if (authLoading) {
return (
);
}
if (!user) {
- return Redirecting to login...
;
+ return null;
}
return (
- setScanDefaults((current) => ({ ...current, ...patch }))}
- queue={queue}
- showOCRSettings={showOCRSettings}
- onOpenOCRSettings={() => setShowOCRSettings(true)}
- onCloseOCRSettings={() => setShowOCRSettings(false)}
- onScannerError={handleScannerError}
- />
+ {phase === 'setup' && (
+ setPhase('scanning')}
+ scanHistory={scanHistory}
+ />
+ )}
+
+ {phase === 'scanning' && (
+ setPhase('review')}
+ onStopSession={() => {
+ camera.stopCamera();
+ setPhase('review');
+ }}
+ sessionDestination={sessionDestination}
+ />
+ )}
+
+ {phase === 'review' && (
+ setPhase('scanning')}
+ onNewSession={() => {
+ queue.clearScannedCards();
+ setPhase('setup');
+ }}
+ sessionDestination={sessionDestination}
+ />
+ )}
);
}