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

246 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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