deckhearth/components/scanner/ScannerReview.js
Randall Stillwell cf9fea0726 feat(scanner): rebuild as mobile-first three-phase flow
Replace the desktop-first, everything-at-once scanner layout with a
phased mobile-optimized experience: Setup → Scanning → Review.

Phase 1 (Setup): destination picker, game filter, deck mode toggle,
scan history (last 5 sessions).

Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound
feedback on card detection, torch/flash toggle, count pill, bottom-sheet
disambiguation (replaces full-screen modal).

Phase 3 (Review): card list with inline condition/foil/qty edits,
batch confirm, 30-second undo, deck progress indicator.

New features:
- Deck mode (progress toward 40/60/99 card target)
- Scan history (persisted to localStorage)
- Sound feedback (Web Audio oscillator, configurable)
- Offline queue (localStorage persistence + auto-retry on reconnect)
- Camera flash/torch toggle
- Batch ownership API (replaces N+1 per-card fetches)
- Visibility pause (detection loop stops when tab is backgrounded)

Convoy: scanner-rebuild
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 08:42:58 -05:00

192 lines
7.3 KiB
JavaScript

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