* 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>
7.4 KiB
7.4 KiB
| convoy | brief_number | depends_on | recommended_model | model_tier | files | cross_brief_commitments | deletes | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| scanner-mobile-checkout | 4 |
|
composer-2.5-fast | fast |
|
|
Brief 4: Page orchestration + checkout sheet
Goal (1 sentence)
Collapse /scanner to scanning-first immersive flow, wire checkout sheet (mobile) / side panel (desktop), auth return URL (D2), and commit CTAs using vocab constants.
Files in scope (do not edit anything else)
pages/scanner.jspages/login.jscomponents/scanner/ScannerCheckoutSheet.js(new)components/scanner/ScannerReview.js(desktopmd+side panel only)components/scanner/ReviewCardItem.jstest/components/ScannerCheckoutSheet.test.js(new)
Conventions to follow
- Vocab:
import { VOCAB } from '../lib/collection-vocabulary.js'— primaryVOCAB.ADD_TO_MY_COLLECTION, secondaryVOCAB.ADD_TO_LIST. Never "Save to binder" / "Mark Owned". - Sheet shell: copy
ScannerDisambiguation.jspattern (fixed inset-0,glass-panel-strong,useFocusTrap, Escape dismiss,z-40— disambiguation staysz-50). - Modal primitive: back-guard and List picker use
<Modal>fromcomponents/ui/Modal.js(notwindow.confirm). - Selection: checkboxes always visible; all unprocessed IDs selected on first sheet open; new enqueue while open auto-selects (Brief 2 + local effect).
- Pause:
verificationPausedRef.current = isCheckoutOpen || isListPickerOpen || identification.disambiguationon mobile only. - Auth:
getUserFromRequestnot needed (page is client-only). Login redirect:
// pages/scanner.js — replace router.push('/login')
router.push(`/login?returnUrl=${encodeURIComponent('/scanner')}`);
// pages/login.js — after successful login, before role redirect:
const returnUrl = typeof router.query.returnUrl === 'string' ? router.query.returnUrl : null;
if (returnUrl && returnUrl.startsWith('/')) {
router.push(returnUrl);
return;
}
- D1: after commit, stay on camera; empty cart closes sheet and resumes scanning.
- D4: no deck destination in checkout UI.
- D6: partial commit keeps sheet open with remaining rows.
pages/scanner.js shape (verified — replace phase machine)
Remove phase state (setup / scanning / review). Default: camera always mounted when authenticated.
export default function Scanner() {
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [isListPickerOpen, setIsListPickerOpen] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const verificationPausedRef = useRef(false);
// useScannerSession: keep scanDefaults only; drop Setup UI (D3)
const queue = useScannerQueue({ user, scanDefaults, deckMode: null });
// ...
return (
<Layout user={user} chrome="immersive">
<div className="relative flex flex-col md:flex-row h-full min-h-0 max-md:fixed max-md:inset-0">
<ScannerCamera
queue={queue}
camera={camera}
identification={identification}
onBack={() => unprocessedCount > 0 ? setShowLeaveModal(true) : router.back()}
onOpenCheckout={() => setIsCheckoutOpen(true)}
onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)}
latestPeekCard={queue.scannedCards[0] ?? null}
cartCount={unprocessedCount}
isCheckoutOpen={isCheckoutOpen}
verificationPausedRef={verificationPausedRef}
/>
{/* Mobile sheet */}
<div className="md:hidden">
{isCheckoutOpen && (
<ScannerCheckoutSheet
queue={queue}
collections={queue.collections}
onClose={() => setIsCheckoutOpen(false)}
onOpenListPicker={() => setIsListPickerOpen(true)}
/>
)}
</div>
{/* Desktop side panel */}
<div className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:border-l" style={{ borderColor: 'var(--border)' }}>
<ScannerReview queue={queue} collections={queue.collections} variant="side-panel" />
</div>
<Modal open={showLeaveModal} /* ... */ />
<Modal open={isListPickerOpen} title="Choose a List" /* ... */ />
</div>
</Layout>
);
}
Sync verificationPausedRef in useEffect when isCheckoutOpen || isListPickerOpen || identification.disambiguation (mobile: checkout open; desktop: only list picker + disambiguation per UX).
ScannerCheckoutSheet responsibilities
- Header:
{N} cards scanned+ low-confidence subline when any rowconfidence < 70 - Rows:
ReviewCardItemwith checkbox, confidence ring, qty stepper, overflow menu (remove / condition / foil) - Footer: primary
VOCAB.ADD_TO_MY_COLLECTIONwith(N)when subset selected; secondaryVOCAB.ADD_TO_LIST - Commit error: inline banner in footer, rows stay selected
role="dialog"aria-modal="true"aria-labelledby- On last item removed →
onClose()+ resume camera
ReviewCardItem changes
- Add optional
selected,onToggleSelect,showCheckbox,confidencering props - Remove inline destination picker from row (destinations in sheet footer only)
aria-labelon checkbox:Select ${card.name}; confidence inaria-labelwhen< 85
ScannerReview changes
- Support
variant="side-panel"for desktop persistent panel (same row/footer semantics as sheet, no scrim) - Remove full-page empty state / "New Session" / deck progress from default path
Acceptance criteria
- Opening
/scannerauthenticated shows camera immediately — no Setup phase (D3) - Unauthenticated redirect includes
returnUrl=/scanner; login honors it (D2) - Mobile checkout sheet opens from Review N and peek; pauses identification under sheet
commitSelectedToOwned/ List commit work; committed rows leave cart; camera resumes when empty (D1/D6)- Back with unprocessed cart shows
<Modal>leave confirm; confirm callsclearScannerCartStorage()+router.back() - Desktop
md+shows side panel with same CTA semantics; Layout sidebar visible test/components/ScannerCheckoutSheet.test.js: renders header count, disabled primary when nothing selected, vocab labels present- No scope expansion
Rationale (≤3 sentences)
Integration brief owns the sole pages/scanner.js writer after Layout and camera contracts exist. Login returnUrl is a two-line change required by D2 and verified absent today. Checkout sheet and desktop panel share row components but split shells to stay under 400 LOC.