Give /scanner a md+ camera, live match inspector, and history strip (with device picker, batch scan, and tips) without regressing the mobile immersive checkout. Co-authored-by: Cursor <cursoragent@cursor.com>
343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
/* eslint-disable @next/next/no-img-element -- External card thumbnails; next/image migration is out of scope. */
|
||
import { useRef } from 'react';
|
||
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||
import Button from '../ui/Button.js';
|
||
import GlassSurface from '../ui/GlassSurface.js';
|
||
|
||
export const TAB_RECENT = 'recent';
|
||
export const TAB_QUEUE = 'queue';
|
||
export const TAB_DUPLICATES = 'duplicates';
|
||
|
||
const EMPTY_COPY = {
|
||
[TAB_RECENT]: 'No scans yet this session.',
|
||
[TAB_QUEUE]: 'Scan queue is empty — matches appear here before you add them.',
|
||
[TAB_DUPLICATES]: 'No duplicates detected.',
|
||
};
|
||
|
||
export function getDuplicateCards(scannedCards, ownershipMap) {
|
||
const sessionRepeatIds = new Set();
|
||
const seen = new Map();
|
||
for (const card of scannedCards) {
|
||
const key = `${card.name}::${card.set}`;
|
||
if (seen.has(key)) sessionRepeatIds.add(card.id);
|
||
else seen.set(key, card.id);
|
||
if ((card.quantity || 1) > 1) sessionRepeatIds.add(card.id);
|
||
}
|
||
return scannedCards.filter(
|
||
(card) =>
|
||
sessionRepeatIds.has(card.id) ||
|
||
(card.databaseId && ownershipMap[card.databaseId])
|
||
);
|
||
}
|
||
|
||
function queueTabAriaLabel(count) {
|
||
if (count === 0) return 'Scan Queue';
|
||
if (count === 1) return 'Scan Queue, 1 unprocessed card';
|
||
return `Scan Queue, ${count} unprocessed cards`;
|
||
}
|
||
|
||
function duplicatesTabAriaLabel(count) {
|
||
if (count === 0) return 'Duplicates';
|
||
if (count === 1) return 'Duplicates, 1 duplicate card';
|
||
return `Duplicates, ${count} duplicate cards`;
|
||
}
|
||
|
||
function HistoryChip({ card, isFocused, onFocus }) {
|
||
const isFailed = Boolean(card.identifyFailed);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onFocus(card.id)}
|
||
className="flex-shrink-0 flex items-center gap-2 rounded-xl px-3 py-2 min-h-[44px] cursor-pointer transition-shadow duration-150 hover:shadow-[var(--rim-light-inner),var(--ember-rim-subtle)] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
style={{
|
||
backgroundColor: 'var(--bg-secondary)',
|
||
border: '1px solid var(--border)',
|
||
borderLeft: isFailed ? '3px solid var(--accent-ember)' : undefined,
|
||
opacity: card.processed ? 0.65 : 1,
|
||
boxShadow: isFocused
|
||
? 'var(--rim-light-inner), var(--ember-rim-pronounced)'
|
||
: undefined,
|
||
'--tw-ring-color': 'var(--accent-ember)',
|
||
'--tw-ring-offset-color': 'transparent',
|
||
}}
|
||
aria-current={isFocused ? 'true' : undefined}
|
||
>
|
||
{card.image_url ? (
|
||
<img
|
||
src={card.image_url}
|
||
alt=""
|
||
className="h-10 w-7 rounded object-cover flex-shrink-0"
|
||
/>
|
||
) : (
|
||
<div
|
||
className="h-10 w-7 rounded flex-shrink-0"
|
||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<div className="min-w-0 text-left">
|
||
<div
|
||
className="text-sm font-medium truncate"
|
||
style={{ color: 'var(--text-primary)' }}
|
||
>
|
||
{card.name}
|
||
</div>
|
||
{isFailed && (
|
||
<div className="text-xs font-medium" style={{ color: 'var(--accent-ember)' }}>
|
||
Identify failed
|
||
</div>
|
||
)}
|
||
{isFailed && card.identifyFailureReason && (
|
||
<div className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
|
||
{card.identifyFailureReason}
|
||
</div>
|
||
)}
|
||
{!isFailed && card.set && (
|
||
<div className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||
{card.set}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(card.quantity || 1) > 1 && (
|
||
<span
|
||
className="text-xs font-semibold rounded-full px-2 py-0.5 flex-shrink-0"
|
||
style={{
|
||
backgroundColor: 'var(--bg-primary)',
|
||
color: 'var(--text-secondary)',
|
||
}}
|
||
>
|
||
×{card.quantity}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function BatchProgressBanner({ batchProgress }) {
|
||
if (!batchProgress?.active) return null;
|
||
|
||
const { current, total, onCancel } = batchProgress;
|
||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||
|
||
return (
|
||
<div
|
||
className="mb-3 rounded-xl px-4 py-3"
|
||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||
aria-live="polite"
|
||
>
|
||
<div className="flex items-center justify-between gap-3 mb-2">
|
||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||
Scanning {current} of {total}…
|
||
</span>
|
||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
<div
|
||
className="h-2 rounded-full overflow-hidden"
|
||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||
role="progressbar"
|
||
aria-valuenow={current}
|
||
aria-valuemin={0}
|
||
aria-valuemax={total}
|
||
aria-label={`Scanning ${current} of ${total}`}
|
||
>
|
||
<div
|
||
className="h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none"
|
||
style={{
|
||
width: `${percent}%`,
|
||
background:
|
||
'linear-gradient(90deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ScannerHistoryStrip({
|
||
scannedCards,
|
||
ownershipMap,
|
||
focusedCardId,
|
||
onFocusCard,
|
||
activeTab,
|
||
onTabChange,
|
||
batchProgress = null,
|
||
onClearAll,
|
||
onCommitSelectedToOwned,
|
||
onOpenListPicker,
|
||
isProcessing = false,
|
||
}) {
|
||
const chipScrollerRef = useRef(null);
|
||
|
||
const unprocessedCards = scannedCards.filter((card) => !card.processed);
|
||
const duplicateCards = getDuplicateCards(scannedCards, ownershipMap || {});
|
||
const recentCards = [...scannedCards].reverse();
|
||
|
||
const tabCards = {
|
||
[TAB_RECENT]: recentCards,
|
||
[TAB_QUEUE]: unprocessedCards,
|
||
[TAB_DUPLICATES]: duplicateCards,
|
||
};
|
||
|
||
const visibleCards = tabCards[activeTab] ?? [];
|
||
|
||
const handleViewAllScans = () => {
|
||
const scroller = chipScrollerRef.current;
|
||
if (scroller) {
|
||
scroller.scrollLeft = scroller.scrollWidth;
|
||
}
|
||
};
|
||
|
||
const tabs = [
|
||
{
|
||
id: TAB_RECENT,
|
||
label: 'Recent Scans',
|
||
badge: null,
|
||
ariaLabel: 'Recent Scans',
|
||
},
|
||
{
|
||
id: TAB_QUEUE,
|
||
label: 'Scan Queue',
|
||
badge: unprocessedCards.length,
|
||
ariaLabel: queueTabAriaLabel(unprocessedCards.length),
|
||
},
|
||
{
|
||
id: TAB_DUPLICATES,
|
||
label: 'Duplicates',
|
||
badge: duplicateCards.length,
|
||
ariaLabel: duplicatesTabAriaLabel(duplicateCards.length),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<GlassSurface tint="mid" className="rounded-2xl p-4 w-full">
|
||
<div className="flex items-center justify-between gap-4 mb-3">
|
||
<div role="tablist" aria-label="Scan history" className="flex gap-1 flex-wrap">
|
||
{tabs.map((tab) => {
|
||
const isSelected = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
id={`tab-${tab.id}`}
|
||
aria-selected={isSelected}
|
||
aria-controls={`panel-${tab.id}`}
|
||
aria-label={tab.ariaLabel}
|
||
onClick={() => onTabChange(tab.id)}
|
||
className="relative px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
style={{
|
||
color: isSelected ? 'var(--accent-ember)' : 'var(--text-secondary)',
|
||
backgroundColor: isSelected
|
||
? 'color-mix(in srgb, var(--accent-ember) 12%, transparent)'
|
||
: 'transparent',
|
||
boxShadow: isSelected
|
||
? 'inset 0 -2px 0 var(--accent-ember)'
|
||
: undefined,
|
||
'--tw-ring-color': 'var(--accent-ember)',
|
||
'--tw-ring-offset-color': 'transparent',
|
||
}}
|
||
>
|
||
<span aria-hidden="true">
|
||
{tab.label}
|
||
{tab.badge != null && tab.badge > 0 && (
|
||
<span
|
||
className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-semibold"
|
||
style={{
|
||
backgroundColor:
|
||
'color-mix(in srgb, var(--accent-ember) 18%, var(--bg-secondary))',
|
||
color: 'var(--accent-ember)',
|
||
}}
|
||
>
|
||
{tab.badge}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onClearAll}
|
||
disabled={batchProgress?.active}
|
||
className="text-sm font-medium whitespace-nowrap transition-opacity duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
style={{
|
||
color: 'var(--accent-ember)',
|
||
'--tw-ring-color': 'var(--accent-ember)',
|
||
'--tw-ring-offset-color': 'transparent',
|
||
}}
|
||
>
|
||
Clear All
|
||
</button>
|
||
</div>
|
||
|
||
{tabs.map((tab) => (
|
||
<div
|
||
key={tab.id}
|
||
role="tabpanel"
|
||
id={`panel-${tab.id}`}
|
||
aria-labelledby={`tab-${tab.id}`}
|
||
hidden={activeTab !== tab.id}
|
||
>
|
||
{tab.id === TAB_QUEUE && <BatchProgressBanner batchProgress={batchProgress} />}
|
||
|
||
{visibleCards.length === 0 && activeTab === tab.id ? (
|
||
<p
|
||
className="text-sm py-4 text-center"
|
||
style={{ color: 'var(--text-secondary)' }}
|
||
>
|
||
{EMPTY_COPY[tab.id]}
|
||
</p>
|
||
) : activeTab === tab.id ? (
|
||
<div
|
||
ref={chipScrollerRef}
|
||
className="flex gap-2 overflow-x-auto pb-1 min-h-[44px]"
|
||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||
>
|
||
{visibleCards.map((card) => (
|
||
<HistoryChip
|
||
key={card.id}
|
||
card={card}
|
||
isFocused={focusedCardId === card.id}
|
||
onFocus={onFocusCard}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{tab.id === TAB_QUEUE && activeTab === TAB_QUEUE && unprocessedCards.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
loading={isProcessing}
|
||
disabled={isProcessing}
|
||
onClick={onCommitSelectedToOwned}
|
||
>
|
||
{VOCAB.ADD_TO_MY_COLLECTION}
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={isProcessing}
|
||
onClick={onOpenListPicker}
|
||
>
|
||
{VOCAB.ADD_TO_LIST}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="mt-3 flex justify-center">
|
||
<Button variant="ghost" size="sm" onClick={handleViewAllScans}>
|
||
View All Scans
|
||
</Button>
|
||
</div>
|
||
</GlassSurface>
|
||
);
|
||
}
|