deckhearth/.convoys/scanner-mobile-checkout/brief-4-page-orchestration-checkout-sheet.md
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

7.4 KiB

convoy brief_number depends_on recommended_model model_tier files cross_brief_commitments deletes
scanner-mobile-checkout 4
1
2
3
composer-2.5-fast fast
pages/scanner.js
pages/login.js
components/scanner/ScannerCheckoutSheet.js
components/scanner/ScannerReview.js
components/scanner/ReviewCardItem.js
test/components/ScannerCheckoutSheet.test.js
brief description
1 Passes `chrome="immersive"` to `<Layout>` on authenticated `/scanner` (mobile immersive; desktop uses default chrome per D3).
brief description
2 Calls `clearScannerCartStorage()` on confirmed leave. Uses `commitSelectedToOwned` / `commitSelectedToCollection` from queue hook. Does not reimplement sessionStorage persistence.
brief description
3 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).

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