160 lines
7.4 KiB
Markdown
160 lines
7.4 KiB
Markdown
|
|
---
|
||
|
|
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.
|