deckhearth/components/scanner/ScannerCheckoutSheet.js
varutasu 73424aae59
Mobile scanner checkout: scan first, commit later (#157)
* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow.

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

* Ship a cart-then-commit mobile scanner so phone sessions stay on the camera.

Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 20:20:43 -05:00

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