deckhearth/components/ScannedCardItem.js
varutasu fd781140e5
Align UI copy: My Collection vs Lists (#54)
* Align UI copy with My Collection vs Lists vocabulary.

Replace stale ownership/list labels across pages and components, add
lib/collection-vocabulary.js as the single copy source, document the
taxonomy in AGENTS.md, and gate retired strings in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix remaining list/collection copy gaps from review.

Sweep community, settings, share modal, scanner create-list modal,
and invite flows for vocabulary consistency before merge.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 09:53:40 -05:00

301 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

import { useEffect, useState } from 'react';
import { formatProcessedDestination, VOCAB } from '../lib/collection-vocabulary.js';
export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
export default function ScannedCardItem({
card,
collections,
decks,
selected,
onToggleSelect,
onIncrement,
onDecrement,
onUpdateMetadata,
onMarkOwned,
onAddToCollection,
onAddToDeck,
onRemove,
isAdding = false,
}) {
const [ownedQuantity, setOwnedQuantity] = useState(null);
useEffect(() => {
if (!card.databaseId) {
setOwnedQuantity(null);
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(`/api/cards/${card.databaseId}/ownership`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) return;
const data = await response.json();
if (!cancelled) {
setOwnedQuantity(typeof data.quantity === 'number' ? data.quantity : 0);
}
} catch {
if (!cancelled) setOwnedQuantity(null);
}
})();
return () => {
cancelled = true;
};
}, [card.databaseId]);
return (
<div
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: selected ? 'var(--accent-ember)' : 'var(--border)',
borderWidth: selected ? '2px' : '1px',
}}
>
<div className="relative flex-shrink-0">
<div className="w-20 h-28 rounded-lg overflow-hidden flex items-center justify-center"
style={{ backgroundColor: 'var(--bg-secondary)' }}>
{card.image_url ? (
<img src={card.image_url} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs p-2" style={{ color: 'var(--text-secondary)' }}>
<div className="text-2xl mb-1">🃏</div>
<div>No Image</div>
</div>
)}
</div>
{!card.processed && (
<div className="absolute top-1 left-1">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
className="w-5 h-5 rounded border-2 border-white shadow-lg"
style={{ accentColor: 'var(--accent-ember)' }}
aria-label={`Select ${card.name}`}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
{card.confidence && (
<div
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
style={{
backgroundColor:
card.confidence >= 90
? 'var(--accent-gold)'
: card.confidence >= 70
? 'var(--accent-ember)'
: 'var(--text-secondary)',
color: 'white',
}}
>
{Math.round(card.confidence)}% confidence
</div>
)}
<div className="flex items-start justify-between mb-2 gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
{card.isExisting && (
<div className="text-xs font-medium" style={{ color: 'var(--accent-gold)' }}>
<span aria-hidden="true"> </span>
<span>Found in database</span>
</div>
)}
{ownedQuantity !== null && ownedQuantity > 0 && (
<div
role="status"
className="text-xs font-medium mt-1"
style={{ color: 'var(--text-secondary)' }}
aria-label={`You already own ${ownedQuantity} copies of this card`}
>
You own {ownedQuantity}
</div>
)}
</div>
{!card.processed && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={onDecrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
aria-label="Decrease quantity"
>
</button>
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
{card.quantity || 1}
</span>
<button
type="button"
onClick={onIncrement}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
aria-label="Increase quantity"
>
+
</button>
</div>
)}
</div>
{!card.processed && (
<div className="flex flex-wrap items-center gap-3 mb-3">
<label className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>Condition</span>
<select
value={card.condition || 'NM'}
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
className="px-2 py-1 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)',
}}
>
{CONDITION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={Boolean(card.isFoil)}
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
className="rounded"
style={{ accentColor: 'var(--accent-ember)' }}
/>
Foil
</label>
</div>
)}
<div className="space-y-1 mb-3">
{card.set && (
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Set:</span> {card.set}
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
</div>
)}
{card.cardNumber && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Number:</span> {card.cardNumber}
</div>
)}
{!card.processed && (card.condition || card.isFoil) && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Adding as:</span>{' '}
{card.condition || 'NM'}
{card.isFoil ? ' · Foil' : ''}
</div>
)}
</div>
{!card.processed ? (
<div className="space-y-2">
<div className="flex gap-2">
<button
type="button"
onClick={onMarkOwned}
disabled={isAdding}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
<span aria-hidden="true">💎 </span>
{isAdding ? 'Adding…' : VOCAB.ADD_TO_MY_COLLECTION}
</button>
<button
type="button"
onClick={onRemove}
className="px-3 py-2 rounded text-sm hover:opacity-80"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
aria-label={`Remove ${card.name} from scan queue`}
>
<span aria-hidden="true">🗑</span>
</button>
</div>
<div className="flex gap-2">
{collections.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToCollection(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a list`}
>
<option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
e.target.value = '';
if (value) onAddToDeck(value);
}}
className="flex-1 px-3 py-2 rounded text-sm"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
}}
defaultValue=""
aria-label={`Add ${card.name} to a deck`}
>
<option value="">🃏 Add to Deck</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
)}
</div>
</div>
) : (
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
<span aria-hidden="true"></span>
<span>Added to {formatProcessedDestination(card.processedAction)}</span>
</div>
)}
</div>
</div>
);
}