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.name} + ) : ( +
+ +
+ )} +
+ + {/* Card info + controls */} +
+
+
+
+ {card.processed ? ( + + ) : ( +
+

+ {[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 */} +
+ ); +} 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 && ( +
    + {history.map((entry, i) => ( +
  • +
    + + {entry.cardCount} {entry.cardCount === 1 ? 'card' : 'cards'} + + + {destinationLabel(entry.destination)} + {entry.game && entry.game !== 'All' ? ` · ${entry.game}` : ''} + +
    + + {formatDate(entry.date)} + +
  • + ))} +
+ )} +
+ ); +} + +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 ( +
+
+
+

+ Card Scanner +

+

+ Choose where scanned cards will go +

+
+ + {/* Destination picker */} +
+ + Destination + + + + {destinationType === 'collection' && ( +
+ + +
+ )} + + {destinationType === 'deck' && ( +
+ + +
+ )} +
+ + {/* Game filter */} +
+ + Game + + +
+ + {/* Deck mode toggle */} +
+ + +
+ + {deckMode && ( +
+ + Deck Size + + +
+ )} + + {/* 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 ( +
+
+ + + {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} + /> + )} ); }