deckhearth/.convoys/scanner-mobile-checkout/brief-4-page-orchestration-checkout-sheet.md

160 lines
7.4 KiB
Markdown
Raw Permalink Normal View History

---
convoy: scanner-mobile-checkout
brief_number: 4
depends_on: [1, 2, 3]
recommended_model: composer-2.5-fast
model_tier: fast
files:
- pages/scanner.js
- pages/login.js
- components/scanner/ScannerCheckoutSheet.js
- components/scanner/ScannerReview.js
- components/scanner/ReviewCardItem.js
- test/components/ScannerCheckoutSheet.test.js
cross_brief_commitments:
- brief: 1
description: |
Passes `chrome="immersive"` to `<Layout>` on authenticated `/scanner`
(mobile immersive; desktop uses default chrome per D3).
- brief: 2
description: |
Calls `clearScannerCartStorage()` on confirmed leave. Uses
`commitSelectedToOwned` / `commitSelectedToCollection` from queue hook.
Does not reimplement sessionStorage persistence.
- brief: 3
description: |
Wires `ScannerCamera` props (`onBack`, `onOpenCheckout`, `latestPeekCard`,
`onGalleryIdentify`, `cartCount`, `isCheckoutOpen`, `verificationPausedRef`).
Sets `verificationPausedRef.current = true` when checkout sheet or List
picker modal is open (mobile); desktop side panel does not pause (UX spec).
deletes: []
---
# 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.js`
- `pages/login.js`
- `components/scanner/ScannerCheckoutSheet.js` (new)
- `components/scanner/ScannerReview.js` (desktop `md+` side panel only)
- `components/scanner/ReviewCardItem.js`
- `test/components/ScannerCheckoutSheet.test.js` (new)
## Conventions to follow
- **Vocab:** `import { VOCAB } from '../lib/collection-vocabulary.js'` — primary `VOCAB.ADD_TO_MY_COLLECTION`, secondary `VOCAB.ADD_TO_LIST`. Never "Save to binder" / "Mark Owned".
- **Sheet shell:** copy `ScannerDisambiguation.js` pattern (`fixed inset-0`, `glass-panel-strong`, `useFocusTrap`, Escape dismiss, `z-40` — disambiguation stays `z-50`).
- **Modal primitive:** back-guard and List picker use `<Modal>` from `components/ui/Modal.js` (not `window.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.disambiguation` on mobile only.
- **Auth:** `getUserFromRequest` not needed (page is client-only). Login redirect:
```js
// 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.
```js
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 row `confidence < 70`
- Rows: `ReviewCardItem` with checkbox, confidence ring, qty stepper, overflow menu (remove / condition / foil)
- Footer: primary `VOCAB.ADD_TO_MY_COLLECTION` with `(N)` when subset selected; secondary `VOCAB.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`, `confidence` ring props
- Remove inline destination picker from row (destinations in sheet footer only)
- `aria-label` on checkbox: `Select ${card.name}`; confidence in `aria-label` when `< 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 `/scanner` authenticated 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 calls `clearScannerCartStorage()` + `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.