275 lines
8 KiB
JavaScript
275 lines
8 KiB
JavaScript
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
|
import { useFocusTrap } from '../../lib/use-focus-trap';
|
||
|
|
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||
|
|
import { Button } from '../ui';
|
||
|
|
import ReviewCardItem from './ReviewCardItem';
|
||
|
|
|
||
|
|
function confidencePercent(confidence) {
|
||
|
|
if (confidence == null || Number.isNaN(confidence)) return null;
|
||
|
|
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
|
||
|
|
}
|
||
|
|
|
||
|
|
function CheckoutFooter({
|
||
|
|
selectedCount,
|
||
|
|
isProcessing,
|
||
|
|
commitError,
|
||
|
|
onCommitOwned,
|
||
|
|
onOpenListPicker,
|
||
|
|
}) {
|
||
|
|
const selectHintId = 'checkout-select-hint';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="px-4 sm:px-6 py-4 glass-panel-strong"
|
||
|
|
style={{
|
||
|
|
borderTop: '1px solid var(--border)',
|
||
|
|
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{commitError && (
|
||
|
|
<div
|
||
|
|
className="mb-3 px-3 py-2 rounded-lg text-sm"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||
|
|
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
|
||
|
|
color: 'var(--color-error)',
|
||
|
|
}}
|
||
|
|
role="alert"
|
||
|
|
>
|
||
|
|
{commitError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{selectedCount === 0 && (
|
||
|
|
<p
|
||
|
|
id={selectHintId}
|
||
|
|
className="mb-2 text-sm"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
Select at least one card
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
size="lg"
|
||
|
|
className="w-full"
|
||
|
|
onClick={onCommitOwned}
|
||
|
|
disabled={selectedCount === 0}
|
||
|
|
loading={isProcessing}
|
||
|
|
aria-describedby={selectedCount === 0 ? selectHintId : undefined}
|
||
|
|
>
|
||
|
|
{selectedCount > 0
|
||
|
|
? `${VOCAB.ADD_TO_MY_COLLECTION} (${selectedCount})`
|
||
|
|
: VOCAB.ADD_TO_MY_COLLECTION}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="lg"
|
||
|
|
className="w-full mt-2"
|
||
|
|
onClick={onOpenListPicker}
|
||
|
|
disabled={selectedCount === 0 || isProcessing}
|
||
|
|
>
|
||
|
|
{VOCAB.ADD_TO_LIST}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ScannerCheckoutContent({
|
||
|
|
queue,
|
||
|
|
onOpenListPicker,
|
||
|
|
onEmpty,
|
||
|
|
headerId = 'checkout-sheet-title',
|
||
|
|
}) {
|
||
|
|
const [commitError, setCommitError] = useState(null);
|
||
|
|
const initialSelectionDoneRef = useRef(false);
|
||
|
|
|
||
|
|
const cartCards = queue.scannedCards.filter((card) => !card.processed);
|
||
|
|
const selectedCount = cartCards.filter((card) => queue.selectedCards.has(card.id)).length;
|
||
|
|
const lowConfidenceCount = cartCards.filter((card) => {
|
||
|
|
const pct = confidencePercent(card.confidence);
|
||
|
|
return pct != null && pct < 70;
|
||
|
|
}).length;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (initialSelectionDoneRef.current) return;
|
||
|
|
cartCards.forEach((card) => {
|
||
|
|
if (!queue.selectedCards.has(card.id)) {
|
||
|
|
queue.toggleCardSelection(card.id);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
initialSelectionDoneRef.current = true;
|
||
|
|
}, [cartCards, queue]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (cartCards.length === 0) {
|
||
|
|
onEmpty?.();
|
||
|
|
}
|
||
|
|
}, [cartCards.length, onEmpty]);
|
||
|
|
|
||
|
|
const handleCommitOwned = async () => {
|
||
|
|
setCommitError(null);
|
||
|
|
const beforeIds = cartCards
|
||
|
|
.filter((card) => queue.selectedCards.has(card.id))
|
||
|
|
.map((card) => card.id);
|
||
|
|
|
||
|
|
if (beforeIds.length === 0) return;
|
||
|
|
|
||
|
|
const successfulIds = await queue.commitSelectedToOwned();
|
||
|
|
if (!successfulIds?.length) {
|
||
|
|
setCommitError('Could not add cards. Try again.');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className="px-4 sm:px-6 pb-2">
|
||
|
|
<h2
|
||
|
|
id={headerId}
|
||
|
|
className="text-lg font-semibold"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{cartCards.length} {cartCards.length === 1 ? 'card' : 'cards'} scanned
|
||
|
|
</h2>
|
||
|
|
{lowConfidenceCount > 0 && (
|
||
|
|
<p className="text-sm mt-1" style={{ color: 'var(--accent-ember)' }}>
|
||
|
|
{lowConfidenceCount} {lowConfidenceCount === 1 ? 'needs' : 'need'} review
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className="flex-1 overflow-y-auto px-4 sm:px-6 py-3"
|
||
|
|
style={{ minHeight: 0 }}
|
||
|
|
>
|
||
|
|
{cartCards.map((card) => (
|
||
|
|
<ReviewCardItem
|
||
|
|
key={card.id}
|
||
|
|
card={card}
|
||
|
|
selected={queue.selectedCards.has(card.id)}
|
||
|
|
onToggleSelect={() => queue.toggleCardSelection(card.id)}
|
||
|
|
showCheckbox
|
||
|
|
confidence={card.confidence}
|
||
|
|
onIncrement={() => queue.incrementCardQuantity(card.id)}
|
||
|
|
onDecrement={() => queue.decrementCardQuantity(card.id)}
|
||
|
|
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
|
||
|
|
onRemove={() => queue.removeScannedCard(card.id)}
|
||
|
|
isAdding={queue.addingCardIds?.has(card.id)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<CheckoutFooter
|
||
|
|
selectedCount={selectedCount}
|
||
|
|
isProcessing={queue.isProcessing}
|
||
|
|
commitError={commitError}
|
||
|
|
onCommitOwned={handleCommitOwned}
|
||
|
|
onOpenListPicker={onOpenListPicker}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ScannerCheckoutSheet({
|
||
|
|
queue,
|
||
|
|
onClose,
|
||
|
|
onOpenListPicker,
|
||
|
|
trapActive = true,
|
||
|
|
}) {
|
||
|
|
const sheetRef = useFocusTrap(trapActive);
|
||
|
|
const backdropRef = useRef(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const backdrop = backdropRef.current;
|
||
|
|
const sheet = sheetRef.current;
|
||
|
|
if (!backdrop || !sheet) return undefined;
|
||
|
|
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
backdrop.dataset.visible = 'true';
|
||
|
|
sheet.dataset.visible = 'true';
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!trapActive) return undefined;
|
||
|
|
|
||
|
|
const handler = (e) => {
|
||
|
|
if (e.key === 'Escape') onClose();
|
||
|
|
};
|
||
|
|
document.addEventListener('keydown', handler);
|
||
|
|
return () => document.removeEventListener('keydown', handler);
|
||
|
|
}, [onClose, sheetRef, trapActive]);
|
||
|
|
|
||
|
|
const handleBackdropClick = useCallback(
|
||
|
|
(e) => {
|
||
|
|
if (e.target === e.currentTarget) onClose();
|
||
|
|
},
|
||
|
|
[onClose]
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={backdropRef}
|
||
|
|
className="fixed inset-0 z-40 checkout-backdrop"
|
||
|
|
onClick={handleBackdropClick}
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-labelledby="checkout-sheet-title"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
ref={sheetRef}
|
||
|
|
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
|
||
|
|
>
|
||
|
|
<div className="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||
|
|
<div
|
||
|
|
className="w-10 h-1 rounded-full"
|
||
|
|
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
|
||
|
|
aria-hidden="true"
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
className="absolute right-3 top-2 flex items-center justify-center rounded-lg"
|
||
|
|
style={{
|
||
|
|
minWidth: 44,
|
||
|
|
minHeight: 44,
|
||
|
|
color: 'var(--text-secondary)',
|
||
|
|
}}
|
||
|
|
aria-label="Close cart"
|
||
|
|
>
|
||
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ScannerCheckoutContent
|
||
|
|
queue={queue}
|
||
|
|
onOpenListPicker={onOpenListPicker}
|
||
|
|
onEmpty={onClose}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<style jsx>{`
|
||
|
|
.checkout-backdrop {
|
||
|
|
background: transparent;
|
||
|
|
backdrop-filter: none;
|
||
|
|
-webkit-backdrop-filter: none;
|
||
|
|
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
|
||
|
|
-webkit-backdrop-filter 300ms ease-out;
|
||
|
|
}
|
||
|
|
.checkout-backdrop[data-visible='true'] {
|
||
|
|
background: var(--modal-scrim);
|
||
|
|
backdrop-filter: blur(var(--glass-blur-high));
|
||
|
|
-webkit-backdrop-filter: blur(var(--glass-blur-high));
|
||
|
|
}
|
||
|
|
.checkout-sheet {
|
||
|
|
max-height: 85vh;
|
||
|
|
transform: translateY(100%);
|
||
|
|
transition: transform 300ms ease-out;
|
||
|
|
}
|
||
|
|
.checkout-sheet[data-visible='true'] {
|
||
|
|
transform: translateY(0);
|
||
|
|
}
|
||
|
|
`}</style>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|