---
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.
