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>
192 lines
7.3 KiB
JavaScript
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>
|
|
);
|
|
}
|