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>
This commit is contained in:
Randall Stillwell 2026-06-13 08:42:58 -05:00
parent 237870c17e
commit cf9fea0726
18 changed files with 2165 additions and 48 deletions

View file

@ -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.

View file

@ -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 (
<div className="mt-2 px-1">
{/* Progress track */}
<div
className="w-full rounded-full overflow-hidden"
style={{
height: 6,
backgroundColor: 'var(--bg-tertiary)',
}}
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={target}
aria-label={`Deck progress: ${current} of ${target} cards`}
>
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${progress * 100}%`,
backgroundColor: isComplete
? 'var(--accent-gold)'
: 'var(--accent-ember)',
}}
/>
</div>
{/* Labels */}
<div className="flex items-center justify-between mt-1.5">
<span
className="text-xs font-medium"
style={{
color: isComplete ? 'var(--accent-gold)' : 'var(--text-secondary)',
textShadow: isComplete ? '0 0 8px var(--accent-gold)' : 'none',
}}
>
{isComplete ? 'Deck complete!' : `${current}/${target} cards`}
</span>
<span
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{game}
</span>
</div>
</div>
);
}

View file

@ -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 (
<div
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
style={{
borderLeft: !card.processed ? '3px solid var(--accent-ember)' : '3px solid transparent',
}}
>
<div className="flex gap-3">
{/* Thumbnail */}
<div className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div
className="w-full h-full flex items-center justify-center rounded-lg text-xs"
style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
>
<svg className="w-5 h-5" 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>
{/* Card info + controls */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{card.processed ? (
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: 'var(--accent-flame)' }}
aria-hidden="true"
/>
)}
<h3
className="font-semibold text-sm truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h3>
</div>
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
</p>
</div>
{/* Remove button (unprocessed only) */}
{!card.processed && (
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
aria-label={`Remove ${card.name} from scan queue`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
{card.processed ? (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Added to {destinationLabel}
</span>
{onUndo && (
<button
type="button"
onClick={onUndo}
className="text-xs font-medium underline transition-opacity duration-150 hover:opacity-70"
style={{ color: 'var(--accent-ember)', minHeight: '44px', display: 'inline-flex', alignItems: 'center' }}
aria-label={`Undo adding ${card.name}`}
>
Undo
</button>
)}
</div>
) : (
<>
{/* Metadata summary line */}
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
</p>
{/* Inline edit controls */}
<div className="flex flex-col sm:flex-row gap-2 mt-2">
{/* Condition */}
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="sr-only">Condition for {card.name}</span>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minHeight: '44px',
}}
aria-label={`Condition for ${card.name}`}
>
{CONDITION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</label>
{/* Foil toggle */}
<label
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
>
<input
type="checkbox"
checked={Boolean(card.isFoil)}
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
className="w-4 h-4 rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span>Foil</span>
</label>
{/* Quantity ± */}
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={onDecrement}
disabled={isAdding || (card.quantity || 1) <= 1}
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
minWidth: '44px',
minHeight: '44px',
}}
aria-label={`Decrease quantity of ${card.name}`}
>
</button>
<span
className="min-w-[1.5rem] text-center text-sm font-medium"
style={{ color: 'var(--text-primary)' }}
aria-live="polite"
aria-label={`Quantity: ${card.quantity || 1}`}
>
{card.quantity || 1}
</span>
<button
type="button"
onClick={onIncrement}
disabled={isAdding}
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
style={{
backgroundColor: 'var(--accent-ember)',
color: '#ffffff',
minWidth: '44px',
minHeight: '44px',
}}
aria-label={`Increase quantity of ${card.name}`}
>
+
</button>
</div>
</div>
{/* Destination override */}
{(collections.length > 0 || decks.length > 0) && (
<div className="mt-2">
<select
value={card.destinationOverride || ''}
onChange={(e) => {
const val = e.target.value;
if (!val) {
onUpdateMetadata({ destinationOverride: null });
} else {
onUpdateMetadata({ destinationOverride: val });
}
}}
className="w-full px-2 py-1.5 rounded-lg text-xs"
style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
minHeight: '44px',
}}
aria-label={`Change destination for ${card.name}`}
>
<option value="">
{sessionDestination ? `Default (${sessionDestination.label})` : 'Change destination'}
</option>
{collections.map((c) => (
<option key={`col-${c.id}`} value={`collection:${c.id}`}>
{c.name}
</option>
))}
{decks.map((d) => (
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
{d.name}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col w-full h-full gap-3">
{/* Camera viewport */}
<div
className="relative w-full overflow-hidden rounded-2xl flex-1"
style={{
minHeight: '60vh',
aspectRatio: '4 / 3',
backgroundColor: 'var(--bg-tertiary)',
}}
>
{/* Video */}
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover"
style={{ display: isStreaming ? 'block' : 'none' }}
autoPlay
playsInline
muted
aria-label="Card scanner camera feed"
/>
{/* Bounding box overlays */}
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
<div
key={card.id}
className="absolute rounded-lg transition-all duration-200 pointer-events-none"
style={{
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
borderWidth: 3,
borderStyle: 'solid',
borderColor: overlayBorderColor(card.status),
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
}}
>
<div
className="absolute -top-7 left-0 px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-md whitespace-nowrap"
style={{ backgroundColor: overlayBorderColor(card.status) }}
>
{card.status === 'scanned' ? 'Scanned' : 'Found'}
</div>
</div>
))}
{/* LIVE indicator */}
{isStreaming && (
<div className="absolute top-3 right-3 z-10">
<div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
color: '#fff',
}}
>
<span
className="w-1.5 h-1.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--color-error, #EF4444)' }}
aria-hidden="true"
/>
LIVE
</div>
</div>
)}
{/* Toast */}
<ScannerToast
message={toast.message}
visible={toast.visible}
type={toast.type}
/>
{/* Bottom controls */}
{isStreaming && (
<div className="absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-4 px-4">
{/* Sound toggle */}
<button
type="button"
onClick={() => sound.setEnabled((v) => !v)}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={sound.enabled ? 'Mute scan sounds' : 'Unmute scan sounds'}
aria-pressed={sound.enabled}
>
{sound.enabled ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
)}
</button>
{/* Stop button */}
<button
type="button"
onClick={handleStop}
className="w-16 h-16 rounded-full flex items-center justify-center shadow-2xl transition-transform active:scale-95"
style={{
backgroundColor: 'rgba(239, 68, 68, 0.9)',
border: '3px solid rgba(255, 255, 255, 0.3)',
}}
aria-label="Stop scanning"
>
<div
className="w-6 h-6 rounded-sm"
style={{ backgroundColor: '#fff' }}
aria-hidden="true"
/>
</button>
{/* Flash toggle */}
{flash.flashSupported && (
<button
type="button"
onClick={flash.toggleFlash}
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
style={{
backgroundColor: flash.flashOn
? 'rgba(255, 171, 64, 0.85)'
: 'rgba(0, 0, 0, 0.55)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
color: '#fff',
}}
aria-label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
aria-pressed={flash.flashOn}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</button>
)}
</div>
)}
{/* Loading state (before stream starts) */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="w-10 h-10 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
aria-hidden="true"
/>
<span
className="text-sm font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Starting camera
</span>
</div>
</div>
)}
</div>
{/* Hidden canvases for detection */}
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
{/* Count pill */}
<ScannerCountPill count={currentCount} onReview={onReview} />
{/* Deck mode progress */}
{deckMode && (
<DeckModeIndicator
current={currentCount}
target={deckMode.targetSize}
game={deckMode.game}
/>
)}
{/* Disambiguation overlay */}
{identification.disambiguation && (
<ScannerDisambiguation
disambiguation={identification.disambiguation}
submittingReview={identification.submittingReview}
onPick={identification.handleDisambiguationPick}
onNotInCatalog={identification.handleNotInCatalog}
onCancel={identification.cancelDisambiguation}
/>
)}
</div>
);
}

View file

@ -0,0 +1,32 @@
export default function ScannerCountPill({ count, onReview }) {
if (count <= 0) return null;
return (
<div className="glass-panel rounded-xl px-4 py-3 flex items-center justify-between">
<span
className="text-sm font-medium"
style={{ color: 'var(--text-primary)' }}
>
{count} {count === 1 ? 'card' : 'cards'} scanned
</span>
<button
type="button"
onClick={onReview}
className="text-sm font-medium underline-offset-2 hover:underline transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded px-2 py-1"
style={{
color: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
minHeight: 44,
minWidth: 44,
display: 'inline-flex',
alignItems: 'center',
}}
aria-label={`Review ${count} scanned cards`}
>
Review
</button>
</div>
);
}

View file

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

View file

@ -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 (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No cards to review
</h2>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Scan some cards first, then come back to review and confirm them.
</p>
<Button variant="primary" size="lg" onClick={onContinueScanning}>
Start Scanning
</Button>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
Review{' '}
<span className="text-sm font-normal" style={{ color: 'var(--text-secondary)' }}>
({scannedCards.length} {scannedCards.length === 1 ? 'card' : 'cards'})
</span>
</h2>
<Button variant="ghost" size="sm" onClick={onContinueScanning}>
Continue
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
{/* Deck progress (if applicable) */}
{deckProgress && (
<div className="px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center justify-between text-sm mb-1.5">
<span style={{ color: 'var(--text-secondary)' }}>
Deck progress
</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{deckProgress.current} / {deckProgress.target}
</span>
</div>
<div
className="w-full h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
role="progressbar"
aria-valuenow={deckProgress.current}
aria-valuemin={0}
aria-valuemax={deckProgress.target}
aria-label="Deck building progress"
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, (deckProgress.current / deckProgress.target) * 100)}%`,
background: 'linear-gradient(90deg, var(--accent-ember), var(--accent-flame))',
}}
/>
</div>
</div>
)}
{/* Scrollable card list */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4" style={{ paddingBottom: '10rem' }}>
{/* Unprocessed cards first */}
{unprocessedCards.length > 0 && (
<div>
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Pending ({unprocessedCards.length})
</h3>
{unprocessedCards.map((card) => (
<ReviewCardItem
key={card.id}
card={card}
collections={collections}
decks={decks}
onIncrement={() => 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}
/>
))}
</div>
)}
{/* Processed cards */}
{processedCards.length > 0 && (
<div className={unprocessedCards.length > 0 ? 'mt-4' : ''}>
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Confirmed ({processedCards.length})
</h3>
{processedCards.map((card) => (
<ReviewCardItem
key={card.id}
card={card}
collections={collections}
decks={decks}
onIncrement={() => {}}
onDecrement={() => {}}
onUpdateMetadata={() => {}}
onRemove={() => {}}
onUndo={() => queue.updateCardMetadata(card.id, { processed: false, processedAction: null })}
isAdding={false}
sessionDestination={sessionDestination}
/>
))}
</div>
)}
</div>
{/* Sticky bottom action bar */}
<div
className="sticky bottom-0 left-0 right-0 px-4 sm:px-6 py-4 glass-panel-strong"
style={{
borderTop: '1px solid var(--border)',
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
}}
>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={handleConfirmAll}
disabled={unprocessedCards.length === 0}
loading={queue.isProcessing}
>
{unprocessedCards.length === 0
? 'All cards confirmed'
: `Confirm All (${unprocessedCards.length})`}
</Button>
<button
type="button"
onClick={onNewSession}
className="w-full mt-2 py-2 text-sm font-medium text-center transition-opacity duration-150 hover:opacity-70"
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
>
New Session
</button>
</div>
</div>
);
}

View file

@ -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 (
<div className={`inline-flex rounded-xl overflow-hidden ${className}`} role="radiogroup" aria-label={name}>
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className="px-4 py-2.5 text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset min-w-[44px] min-h-[44px] flex items-center justify-center"
style={{
backgroundColor: selected ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: selected ? '#ffffff' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
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 (
<div className="mt-6">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={panelId}
className="flex items-center gap-2 text-sm font-medium w-full py-2 focus:outline-none focus-visible:ring-2 rounded-lg px-1 min-h-[44px]"
style={{
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="transition-transform duration-200"
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
>
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Recent Sessions
</button>
{expanded && (
<ul id={panelId} className="mt-2 space-y-2">
{history.map((entry, i) => (
<li
key={entry.date || i}
className="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--text-primary)' }}>
{entry.cardCount} {entry.cardCount === 1 ? 'card' : 'cards'}
</span>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{destinationLabel(entry.destination)}
{entry.game && entry.game !== 'All' ? ` · ${entry.game}` : ''}
</span>
</div>
<span className="text-xs tabular-nums" style={{ color: 'var(--text-tertiary)' }}>
{formatDate(entry.date)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
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 (
<div className="px-4 sm:px-6 py-6 max-w-lg mx-auto">
<div className="glass-panel rounded-2xl p-5 sm:p-6 space-y-6">
<header>
<h1
className="text-xl sm:text-2xl font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Card Scanner
</h1>
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
Choose where scanned cards will go
</p>
</header>
{/* Destination picker */}
<fieldset>
<legend className="text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Destination
</legend>
<ToggleGroup
options={DESTINATION_TYPES}
value={destinationType}
onChange={handleDestinationTypeChange}
name="Scan destination"
className="w-full"
/>
{destinationType === 'collection' && (
<div className="mt-3">
<label htmlFor={destSelectId} className="sr-only">
Choose a list
</label>
<select
id={destSelectId}
value={sessionDestination?.id ?? ''}
onChange={handleListSelect}
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
style={{
...selectStyle,
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{(!collections || collections.length === 0) && (
<option value="" disabled>
No lists available
</option>
)}
{collections?.map((c) => (
<option key={c.id} value={c.id}>
{collectionDisplayName(c)}
</option>
))}
</select>
</div>
)}
{destinationType === 'deck' && (
<div className="mt-3">
<label htmlFor={destSelectId} className="sr-only">
Choose a deck
</label>
<select
id={destSelectId}
value={sessionDestination?.id ?? ''}
onChange={handleDeckSelect}
className="w-full rounded-lg border px-3 py-2.5 text-sm focus:outline-none focus-visible:ring-2 min-h-[44px]"
style={{
...selectStyle,
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{(!decks || decks.length === 0) && (
<option value="" disabled>
No decks available
</option>
)}
{decks?.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
)}
</fieldset>
{/* Game filter */}
<fieldset>
<legend
id={gameGroupId}
className="text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Game
</legend>
<ToggleGroup
options={GAMES}
value={gameFilter}
onChange={onGameFilterChange}
name="Game filter"
className="w-full"
/>
</fieldset>
{/* Deck mode toggle */}
<div className="flex items-center justify-between gap-3 py-1">
<label
htmlFor={deckModeId}
className="text-sm font-medium cursor-pointer select-none"
style={{ color: 'var(--text-primary)' }}
>
Deck Mode
</label>
<button
id={deckModeId}
type="button"
role="switch"
aria-checked={Boolean(deckMode)}
onClick={handleDeckModeToggle}
className="relative inline-flex h-7 w-12 shrink-0 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: deckMode ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<span
aria-hidden="true"
className="pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200"
style={{
backgroundColor: '#ffffff',
transform: deckMode ? 'translate(22px, 4px)' : 'translate(4px, 4px)',
}}
/>
</button>
</div>
{deckMode && (
<fieldset className="pl-1">
<legend
id={deckSizeId}
className="text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
Deck Size
</legend>
<ToggleGroup
options={DECK_SIZES}
value={deckMode.targetSize}
onChange={handleDeckSizeChange}
name="Deck size"
/>
</fieldset>
)}
{/* Start scanning CTA */}
<Button
variant="primary"
size="lg"
onClick={onStartScanning}
className="w-full"
>
Start Scanning
</Button>
{/* Scan history */}
<ScanHistoryList history={scanHistory} />
</div>
</div>
);
}

View file

@ -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 (
<div
role="status"
aria-live="polite"
className="absolute left-1/2 z-20 pointer-events-none"
style={{
bottom: '33%',
transform: `translateX(-50%) translateY(${visible ? '0' : '12px'})`,
opacity: visible ? 1 : 0,
transition: 'opacity 200ms ease, transform 200ms ease',
visibility: visible ? 'visible' : 'hidden',
transitionProperty: 'opacity, transform, visibility',
transitionDelay: visible ? '0ms' : '0ms, 0ms, 200ms',
}}
>
<div
className="flex items-center gap-2 rounded-full px-4 py-3 shadow-lg pointer-events-auto"
style={{
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.9)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: '1px solid var(--border)',
maxWidth: 280,
}}
>
<span
className="text-sm font-bold flex-shrink-0"
style={{ color }}
aria-hidden="true"
>
{glyph}
</span>
<span
className="text-sm font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{message}
</span>
</div>
</div>
);
}

View file

@ -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;

View file

@ -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,
};
}

75
lib/use-scanner-flash.js Normal file
View file

@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Camera torch / flash control.
*
* @param {React.MutableRefObject<MediaStream|null>} 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 };
}

135
lib/use-scanner-offline.js Normal file
View file

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

View file

@ -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,
};
}

102
lib/use-scanner-session.js Normal file
View file

@ -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,
};
}

101
lib/use-scanner-sound.js Normal file
View file

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

View file

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

View file

@ -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 (
<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
className="animate-spin rounded-full h-12 w-12 border-b-2"
style={{ borderColor: 'var(--text-accent)' }}
/>
</div>
</Layout>
);
}
if (!user) {
return <div>Redirecting to login...</div>;
return null;
}
return (
<Layout user={user}>
<ScannerPageView
gameFilter={gameFilter}
onGameFilterChange={handleGameFilterChange}
{phase === 'setup' && (
<ScannerSetup
sessionDestination={sessionDestination}
onDestinationChange={setSessionDestination}
scanDefaults={scanDefaults}
onScanDefaultsChange={(patch) => setScanDefaults((current) => ({ ...current, ...patch }))}
queue={queue}
showOCRSettings={showOCRSettings}
onOpenOCRSettings={() => setShowOCRSettings(true)}
onCloseOCRSettings={() => setShowOCRSettings(false)}
onScannerError={handleScannerError}
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>
);
}