deckhearth/components/scanner/ScannerSetup.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

379 lines
12 KiB
JavaScript

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