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>
This commit is contained in:
parent
c6c1364dd6
commit
73424aae59
28 changed files with 3420 additions and 572 deletions
|
|
@ -75,3 +75,17 @@
|
||||||
{"ts": "2026-06-13T06:20:00Z", "role": "role-architect", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 180, "outcome": "architecture-only"}
|
{"ts": "2026-06-13T06:20:00Z", "role": "role-architect", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 180, "outcome": "architecture-only"}
|
||||||
{"ts": "2026-06-13T06:20:00Z", "role": "role-implementer", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 600, "outcome": "pr-open"}
|
{"ts": "2026-06-13T06:20:00Z", "role": "role-implementer", "convoy": "scanner-disambiguation-render-test", "repo": "tcg-vault", "skip_flags": [], "classification": "ci", "duration_s": 600, "outcome": "pr-open"}
|
||||||
{"ts": "2026-06-14T13:06:36Z", "role": "role-implementer", "convoy": "multi-game-bulk-sync", "repo": "tcg-vault", "skip_flags": [], "brief": 2, "duration_s": 600}
|
{"ts": "2026-06-14T13:06:36Z", "role": "role-implementer", "convoy": "multi-game-bulk-sync", "repo": "tcg-vault", "skip_flags": [], "brief": 2, "duration_s": 600}
|
||||||
|
{"ts": "2026-08-14T23:57:39Z", "role": "role-conductor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "classification": "feature", "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:14:27Z", "role": "role-ia-architect", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 95, "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:15:31Z", "role": "role-ui-designer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:16:36Z", "role": "role-ux-reviewer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:18:40Z", "role": "role-architect", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 420, "model": "composer-2.5", "model_tier": "standard"}
|
||||||
|
{"ts": "2026-08-15T00:19:56Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 1, "duration_s": 240, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:20:23Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 2, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:22:15Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 3, "duration_s": 600, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:24:15Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 4, "duration_s": 600, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
{"ts": "2026-08-15T00:25:33Z", "role": "role-security-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 480, "model": "gpt-5.6-terra-medium", "model_tier": "security"}
|
||||||
|
{"ts": "2026-08-15T00:25:54Z", "role": "role-reviewer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 210, "multitask_group": "audit-scanner-mobile-checkout-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
|
{"ts": "2026-08-15T00:26:05Z", "role": "role-a11y-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 78, "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
|
{"ts": "2026-08-15T00:26:08Z", "role": "role-design-system-auditor", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "duration_s": 82, "multitask_group": "audit-scanner-mobile-checkout-local", "model": "cursor-grok-4.5-high", "model_tier": "audit"}
|
||||||
|
{"ts": "2026-08-15T00:56:39Z", "role": "role-implementer", "convoy": "scanner-mobile-checkout", "repo": "tcg-vault", "skip_flags": [], "brief": 4, "duration_s": 240, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"}
|
||||||
|
|
|
||||||
644
.convoys/scanner-mobile-checkout.md
Normal file
644
.convoys/scanner-mobile-checkout.md
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
---
|
||||||
|
name: scanner-mobile-checkout
|
||||||
|
classification: feature
|
||||||
|
success_metric: |
|
||||||
|
On a phone, opening /scanner goes straight into a full-bleed camera;
|
||||||
|
each successful identify lands in a local cart (not the database);
|
||||||
|
the user can open a checkout sheet, select cards, and commit them
|
||||||
|
to My Collection or a List without leaving the session.
|
||||||
|
skip: []
|
||||||
|
status: open
|
||||||
|
created: 2026-08-14
|
||||||
|
depends_on:
|
||||||
|
- scanner-rebuild
|
||||||
|
- redesign-scanner-flow
|
||||||
|
model_policy:
|
||||||
|
default_session: auto
|
||||||
|
roles:
|
||||||
|
role-conductor: composer-2.5-fast
|
||||||
|
role-architect: composer-2.5
|
||||||
|
role-ia-architect: composer-2.5-fast
|
||||||
|
role-ux-reviewer: composer-2.5-fast
|
||||||
|
role-ui-designer: composer-2.5-fast
|
||||||
|
role-implementer: composer-2.5-fast
|
||||||
|
role-reviewer: cursor-grok-4.5-high
|
||||||
|
role-security-auditor: gpt-5.6-terra-medium
|
||||||
|
role-design-system-auditor: cursor-grok-4.5-high
|
||||||
|
role-a11y-auditor: cursor-grok-4.5-high
|
||||||
|
role-doc-writer: auto
|
||||||
|
escalate_to: claude-sonnet-5-thinking-medium
|
||||||
|
escalate_to_premium: claude-4.6-opus-high-thinking
|
||||||
|
never_premium:
|
||||||
|
- role-reviewer
|
||||||
|
- role-security-auditor
|
||||||
|
- role-design-system-auditor
|
||||||
|
- role-a11y-auditor
|
||||||
|
- role-ui-designer
|
||||||
|
- role-doc-writer
|
||||||
|
design_direction:
|
||||||
|
source: role-ui-designer
|
||||||
|
skill: ui-ux-pro-max
|
||||||
|
skill_version: "2.5.0"
|
||||||
|
version: 1
|
||||||
|
locked_at: 2026-08-14
|
||||||
|
product_type: mobile camera scanner checkout cart trading cards glassmorphism
|
||||||
|
pattern: Immersive camera overlay + bottom-sheet checkout
|
||||||
|
style: Liquid Glass (repo canonical)
|
||||||
|
stack: nextjs
|
||||||
|
layout_reference: image-93781104-69e3-4f0b-b25f-df7c750ef046.png
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convoy: scanner-mobile-checkout
|
||||||
|
|
||||||
|
Turn the scanner into a mobile checkout: scan everything first, then
|
||||||
|
commit the cart. Layout reference is the last attached mock (full-bleed
|
||||||
|
camera, glass overlays, review as a bottom sheet).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The overnight `scanner-rebuild` got us a three-phase machine (Setup →
|
||||||
|
Scanning → Review), but the phone experience is still chrome-heavy:
|
||||||
|
Layout's sidebar, top search bar, and bottom nav compete with the
|
||||||
|
viewfinder; a destination form blocks the camera; and `handleCardScanned`
|
||||||
|
writes each match to the database immediately (rebuild D3). That fights
|
||||||
|
the actual table-scan job — hold the phone, sweep cards, then check out
|
||||||
|
once.
|
||||||
|
|
||||||
|
Users want supermarket-checkout semantics: open scanner → scan all the
|
||||||
|
things → open the cart → add selected cards to My Collection or a List
|
||||||
|
→ leave. No per-card "Add" tap. No setup screen in the way.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Immersive scan chrome (mobile).** Hide Layout sidebar, TopSearchBar,
|
||||||
|
and MobileNavigation while the camera is live. Camera is full-bleed.
|
||||||
|
Overlay only: back, title, optional gallery, detection brackets +
|
||||||
|
status around the tracked card, bottom glass bar (flash, scan status,
|
||||||
|
Review N).
|
||||||
|
- **Cart, not auto-route.** Successful identifies enqueue locally.
|
||||||
|
Nothing POSTs to `user_cards` / collections / decks until checkout.
|
||||||
|
Reverse rebuild D3 for this flow.
|
||||||
|
- **Scan peek.** After a match, a compact non-interactive preview slides
|
||||||
|
up briefly (name + thumbnail + confidence) and auto-dismisses. Tapping
|
||||||
|
it opens the cart. No condition / foil / Add buttons on the peek.
|
||||||
|
- **Checkout sheet.** Bottom sheet over the still-live (but paused)
|
||||||
|
camera. List of cart items with selection, qty, confidence, overflow
|
||||||
|
menu (remove / condition / foil). Primary: add selected to My
|
||||||
|
Collection. Secondary: add selected to a List (picker). Copy from
|
||||||
|
`lib/collection-vocabulary.js` — never "binder" as a button label
|
||||||
|
(mock says "Save to binder"; product vocab is List).
|
||||||
|
- **Camera swap.** Front / rear toggle in the bottom bar. Flash stays
|
||||||
|
rear-only and hides when `torch` is unsupported (iOS Safari).
|
||||||
|
- **Desktop.** Same cart semantics. At `md+`, cart can be a persistent
|
||||||
|
side panel instead of a sheet; do not hide the desktop sidebar.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Identify / OCR / Gemini pipeline (`lib/scanner-card-identify.js`,
|
||||||
|
`pages/api/scan/identify.js`, OpenCV detection). Layer UI on the
|
||||||
|
existing hooks.
|
||||||
|
- Schema / new tables. Cart is client-side session state.
|
||||||
|
- Deck Mode, game pre-filter, scan-history list on the setup screen
|
||||||
|
(rebuild Setup). Revisit as a later convoy if needed.
|
||||||
|
- Estimated total value row from the mock, unless identify payloads
|
||||||
|
already include `market_price` with no extra fetch.
|
||||||
|
- Deleting leftover `components/CameraScanner.js` /
|
||||||
|
`ScannerPageView.js` (pre-rebuild). Separate cleanup.
|
||||||
|
- Changing desktop visual-diff homepage baseline except as a
|
||||||
|
side-effect of Layout's new immersive prop.
|
||||||
|
|
||||||
|
## Roles invoked
|
||||||
|
|
||||||
|
1. `role-ia-architect` — scan → peek → cart → commit flow; where
|
||||||
|
destination choice lives now that Setup is gone.
|
||||||
|
2. `role-ui-designer` — lock the mobile overlay + checkout sheet against
|
||||||
|
the last mock, using Liquid Glass tokens (ui-ux-pro-max is installed).
|
||||||
|
3. `role-ux-reviewer` — cart selection, low-confidence handling, back
|
||||||
|
with unsaved cart, pause-vs-kill camera under the sheet.
|
||||||
|
4. `role-architect` — briefs. Likely: (1) Layout immersive + camera
|
||||||
|
chrome, (2) stop auto-route + cart model, (3) checkout sheet +
|
||||||
|
destinations. `slice_dependencies` must mark what can run in parallel.
|
||||||
|
5. `role-implementer` — per brief.
|
||||||
|
6. Audit fan-out: reviewer + security-auditor + design-system-auditor +
|
||||||
|
a11y-auditor.
|
||||||
|
|
||||||
|
## Todos
|
||||||
|
|
||||||
|
- [x] IA: route/flow/screen inventory for immersive scan + checkout sheet
|
||||||
|
- [x] UI Designer: lock overlay recipe (tokens, not hex) from last mock
|
||||||
|
- [x] UX: cart selection, low-confidence, back-guard, camera pause
|
||||||
|
- [x] Architect: briefs + whether Layout gets `chrome="immersive"`
|
||||||
|
- [ ] Stop auto-route in `use-scanner-queue.js`; scans stay `processed: false` until checkout
|
||||||
|
- [ ] Full-bleed camera; hide app chrome on small viewports
|
||||||
|
- [ ] Bottom bar: flash, status, Review N; add facingMode swap
|
||||||
|
- [ ] Non-interactive scan peek; checkout sheet with bulk + per-card commit
|
||||||
|
- [ ] Pause identification while the sheet is open (`verificationPausedRef`)
|
||||||
|
- [ ] Tests for cart enqueue (no network) and checkout commit paths
|
||||||
|
- [ ] Visual-diff: scanner surfaces + Layout immersive; refresh baselines if chrome changes
|
||||||
|
|
||||||
|
## Predecessor
|
||||||
|
|
||||||
|
`scanner-rebuild` (2026-06-13, overnight) shipped the phase machine,
|
||||||
|
flash hook, toasts, count pill, and review list. This convoy keeps those
|
||||||
|
hooks and **replaces the UX contract**: skip Setup, immersive camera,
|
||||||
|
cart-then-commit instead of auto-route.
|
||||||
|
|
||||||
|
## Conductor notes (build shape)
|
||||||
|
|
||||||
|
Likely file ownership for Architect to refine:
|
||||||
|
|
||||||
|
| Area | Files |
|
||||||
|
| --- | --- |
|
||||||
|
| Immersive shell | `pages/scanner.js`, `components/Layout.js`, `components/MobileNavigation.js` |
|
||||||
|
| Camera chrome | `components/scanner/ScannerCamera.js`, `lib/use-camera-scanner.js`, `lib/use-scanner-flash.js` |
|
||||||
|
| Cart model | `lib/use-scanner-queue.js`, `lib/scanner-session.js` |
|
||||||
|
| Peek + sheet | new `ScannerScanPeek.js`, rewrite `ScannerReview.js` as a sheet; reuse `ScannerDisambiguation.js` |
|
||||||
|
| Copy | `lib/collection-vocabulary.js` (`ADD_TO_MY_COLLECTION`, `ADD_TO_LIST`) |
|
||||||
|
|
||||||
|
Do not rewrite identification. `handleCardScanned` should enqueue only.
|
||||||
|
|
||||||
|
## Multitask dispatch
|
||||||
|
|
||||||
|
Planning is serial: IA → UI Designer → UX → Architect.
|
||||||
|
|
||||||
|
After architect: implementer fan-out only if briefs have `depends_on: []`
|
||||||
|
and disjoint `files:`. Cart-model (stop auto-route) likely blocks the
|
||||||
|
checkout sheet; immersive Layout may be parallel with cart-model if
|
||||||
|
they do not both own `pages/scanner.js`.
|
||||||
|
|
||||||
|
After PR draft: `/multitask` audit fan-out
|
||||||
|
`role-reviewer + role-security-auditor + role-design-system-auditor + role-a11y-auditor`
|
||||||
|
(group id: `audit-scanner-mobile-checkout-<pr>`).
|
||||||
|
|
||||||
|
## IA
|
||||||
|
|
||||||
|
### Affected routes
|
||||||
|
|
||||||
|
- `[modified]` `/scanner` — sole scanner surface. Entry skips Setup and lands on full-bleed camera; cart and checkout live as in-page overlays/sheets on this route (no new URLs). Phase machine collapses from setup → scanning → review into scanning-first with optional cart sheet open/closed.
|
||||||
|
- `[impacted]` `/login` — unauthenticated `/scanner` visits still redirect here (unchanged gate in `pages/scanner.js`). Post-login return target is TBD (see open questions).
|
||||||
|
- `[impacted]` `/dashboard`, Layout sidebar, command palette — entry links to `/scanner` unchanged; users now arrive directly in camera instead of Setup.
|
||||||
|
- `[impacted]` `/my-cards`, `/collections` — not part of the scan flow, but natural post-checkout destinations if the user navigates away after commit. No route or nav changes required.
|
||||||
|
|
||||||
|
No `[new]` routes. Destination choice (My Collection vs List) moves from pre-scan Setup into the checkout sheet on `/scanner` only — **no route changes**.
|
||||||
|
|
||||||
|
### User flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Open /scanner"] --> B["Live camera"]
|
||||||
|
B --> C["Card identified"]
|
||||||
|
C --> D["Scan peek"]
|
||||||
|
D --> B
|
||||||
|
B --> E["Review N"]
|
||||||
|
E --> F["Checkout sheet"]
|
||||||
|
F --> G["Add to My Collection / List"]
|
||||||
|
G --> H["Exit scanner"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screen inventory
|
||||||
|
|
||||||
|
| Screen | Path | New/modified | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Scanner — live camera | `/scanner` | modified | Default view on open (mobile: immersive, no Layout chrome). Bottom glass bar: flash, status, camera swap, Review N. Detection brackets + status around tracked card. |
|
||||||
|
| Scan peek | `/scanner` (overlay) | new | Brief non-interactive slide-up after identify (name, thumbnail, confidence). Auto-dismiss; tap opens checkout sheet. No per-card Add / condition / foil on peek. |
|
||||||
|
| Checkout cart sheet | `/scanner` (overlay) | modified | Replaces full-page `ScannerReview` as primary cart UI on mobile. Camera stays mounted but paused under sheet. Selection, qty, confidence, overflow (remove / condition / foil). Primary: Add to My Collection; secondary: Add to List (picker). |
|
||||||
|
| Disambiguation sheet | `/scanner` (overlay) | impacted | Existing `ScannerDisambiguation` stays in overlay stack above camera; blocks identify until resolved, then card enqueues to cart. |
|
||||||
|
| Scanner Setup | `/scanner` (phase) | impacted | Removed from default entry path (out of scope to delete component). Game filter, deck mode, pre-scan destination, and scan history no longer gate camera start. |
|
||||||
|
| Auth loading | `/scanner` | impacted | Spinner while `useAuth` resolves; immersive chrome applies once authenticated (see open question on loading shell). |
|
||||||
|
|
||||||
|
Desktop (`md+`): same `/scanner` route; cart may render as persistent side panel instead of sheet; Layout sidebar remains visible.
|
||||||
|
|
||||||
|
### Content / data model deltas
|
||||||
|
|
||||||
|
- **Copy (write / wire):** checkout CTAs from `lib/collection-vocabulary.js` — `ADD_TO_MY_COLLECTION`, `ADD_TO_LIST` / `ADD_TO_LISTS`; bottom-bar "Review N" (N = cart count); flash and camera-swap `aria-label`s; empty-cart and commit-success toasts. Never use "binder" or "Save to binder" as a button label.
|
||||||
|
- **Copy (retire from entry path):** Setup destination picker, game pre-filter, deck-mode selector, and scan-history list as pre-scan gate copy (component may remain for later convoy).
|
||||||
|
- **Client session state:** cart is in-memory queue on `/scanner` — cards stay `processed: false` until checkout commit. Reverses rebuild D3 auto-route; no persistence across refresh or navigation away.
|
||||||
|
- **API calls (commit-time only, unchanged endpoints):** `POST /api/user-cards` (My Collection), `POST /api/collections/:id/cards` (List), existing `GET /api/collections` + `GET /api/decks` for destination lists. Optional `GET` batch-ownership during cart review. No schema / table changes.
|
||||||
|
- **Immersive Layout:** new chrome mode (likely `chrome="immersive"` on `Layout`) affects how `/scanner` composes global nav — content delta only on this page, not a new route.
|
||||||
|
|
||||||
|
### Open IA questions
|
||||||
|
|
||||||
|
1. **Post-checkout exit:** Product intent says "then exit" — does the user return to the prior page (`router.back()`), land on `/my-cards` or `/collections`, or stay on `/scanner` with an empty cart and live camera?
|
||||||
|
2. **Post-login deep link:** If an unauthenticated user hits `/scanner`, should login return them to `/scanner` (camera) or a safer default (`/dashboard`)?
|
||||||
|
3. **Desktop Setup:** Is Setup skipped on `md+` as well, or only on mobile viewports?
|
||||||
|
4. **Deck destination:** Deck mode and add-to-deck are out of scope — confirm deck is fully absent from checkout destinations (not just hidden behind Setup).
|
||||||
|
5. **Gallery import:** Convoy mentions "optional gallery" in the top overlay — is this in-scope as a file-picker overlay on `/scanner`, or deferred?
|
||||||
|
6. **Partial commit:** After adding a subset to My Collection / a List, does the user remain in the sheet with remaining items, or does any successful commit close the session?
|
||||||
|
|
||||||
|
## Decisions (post-IA)
|
||||||
|
|
||||||
|
Locked 2026-08-14 from product intent (parent session). UX / UI / Architect treat these as settled.
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| D1 | **Stay on camera after commit.** Successful checkout removes committed rows from the cart. If the cart is empty, close the sheet and resume scanning. Back (header) is the only exit from `/scanner`. |
|
||||||
|
| D2 | **Login returns to `/scanner`.** Keep the existing auth gate; after login, send the user back to the camera, not `/dashboard`. |
|
||||||
|
| D3 | **Skip Setup on all viewports.** Desktop uses the same scanning-first entry; cart is a side panel at `md+`, not a return of the Setup form. |
|
||||||
|
| D4 | **No deck destination in checkout.** My Collection and List only. Deck Mode stays out of this convoy. |
|
||||||
|
| D5 | **Gallery is in-scope.** Top-right control opens a file picker; chosen image goes through the existing identify path and lands in the cart like a live scan. |
|
||||||
|
| D6 | **Partial commit stays in the sheet.** Uncommitted rows remain. Sheet closes only when the cart is empty or the user dismisses it. |
|
||||||
|
| D7 | **Persist cart in `sessionStorage`.** Survive refresh within the tab; do not persist across browser sessions. |
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Locked v1 — 2026-08-14. Layout authority: last mock
|
||||||
|
(`image-93781104-69e3-4f0b-b25f-df7c750ef046.png`). Generator query:
|
||||||
|
*"mobile camera scanner checkout cart trading cards glassmorphism"*.
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Deck Hearth's mobile scanner should feel like a **supermarket checkout lane
|
||||||
|
over a live viewfinder**: full-bleed camera, ember-bracketed detection,
|
||||||
|
glass overlays that float above card art, a brief non-interactive peek after
|
||||||
|
each match, and a bottom-sheet cart for bulk commit. Warm ember/flame accents
|
||||||
|
cast light onto glass — never fill entire panels with brand orange.
|
||||||
|
|
||||||
|
### Pattern + style
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Product type | Mobile TCG collection scanner with session cart + checkout |
|
||||||
|
| App pattern | **Immersive camera overlay + bottom-sheet checkout** (generator's "Bento Grid Showcase" rejected — marketing grid, not camera UX) |
|
||||||
|
| UI style | **Liquid Glass** — repo canonical (`docs/DESIGN_TOKENS.md`). Generator's "Exaggerated Minimalism" / oversized typography rejected. |
|
||||||
|
| Stack notes | Next.js Pages router, React 18, Tailwind + CSS variables. Next.js stack query returned 0 rows — follow existing scanner components + `components/ui/` primitives. |
|
||||||
|
| Desktop (`md+`) | Same cart semantics; cart as persistent side panel; Layout sidebar stays visible. Immersive chrome is mobile-only. |
|
||||||
|
|
||||||
|
### Screen 1 — Live camera (mobile default)
|
||||||
|
|
||||||
|
Full-bleed `<video>` under all chrome. Layout sidebar, TopSearchBar, and
|
||||||
|
MobileNavigation hidden via `chrome="immersive"` on `/scanner`.
|
||||||
|
|
||||||
|
| Zone | Composition | Tokens / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Top bar** | Back (←), title "Scan Cards", gallery (file picker, D5) | `--glass-surface-mid` + `blur(--glass-blur-mid)` + rim stack. Safe-area inset top. 44×44 tap targets. |
|
||||||
|
| **Viewfinder brackets** | Four L-shaped corner brackets framing tracked card | Stroke `--accent-flame` / `--accent-ember`; no filled box. Animated scan line: horizontal ember gradient sweep (`--gradient-primary-*`), respect `prefers-reduced-motion`. |
|
||||||
|
| **Scan status** | Centered label below brackets ("Scanning… Hold steady") | `--text-primary` on semi-opaque pill or direct over darkened letterbox — ensure 4.5:1. |
|
||||||
|
| **Success toast** | Glass pill: check + "{Card name} added" + dismiss × | `--glass-surface-high` + rim; green check via success token (not emoji). Auto-dismiss ~2s. `role="status"`. |
|
||||||
|
| **Scan peek** | Compact slide-up after identify (name, thumb, confidence ring) | `--glass-surface-mid`; **non-interactive** except tap-to-open-sheet. No Add / condition / foil controls. Auto-dismiss ~3s. |
|
||||||
|
| **Bottom bar** | Three zones: Flash \| status \| **Review N** pill | Bar: `--glass-surface-mid` + blur mid + `--elevation-ambient`. Flash + camera-swap icons (rear-only torch; hide flash when unsupported). **Review N**: gradient pill `background: var(--gradient-primary-*)` + `--ember-rim-pronounced`; label `Review {N}` with chevron. Min 44px height. |
|
||||||
|
|
||||||
|
Camera overlays **may** use `backdrop-filter` (exception to per-card grid
|
||||||
|
rule — overlays sit above the viewfinder, not on grid items).
|
||||||
|
|
||||||
|
### Screen 2 — Checkout sheet (mobile)
|
||||||
|
|
||||||
|
Bottom sheet over **paused** (not unmounted) camera. Scrim + sheet follow
|
||||||
|
Modal primitive recipes.
|
||||||
|
|
||||||
|
| Zone | Composition | Tokens / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Scrim** | Dim live camera | `--modal-scrim` + `blur(--glass-blur-high)` |
|
||||||
|
| **Sheet panel** | Rounded top (~16px), grab handle, scrollable list, sticky footer | Panel: `--glass-surface-low` inside scrim + blur mid + `--elevation-pronounced`. Handle: 36×4px `--border` pill, centered. |
|
||||||
|
| **Header** | "{N} cards scanned" + low-confidence subline (orange dot) + Edit | Subline only when any row < threshold. Edit toggles bulk-select mode (UX brief). |
|
||||||
|
| **Cart rows** | Checkbox, thumb, name/set/rarity, confidence ring, qty stepper (− N +), overflow ⋮ | Row surface: `--glass-surface-high` or solid `--bg-secondary` inner panel over sheet (legibility over busy thumbs). Confidence ring: green ≥85%, amber 70–84%, orange <70% using `--accent-ember` / success tokens — not hardcoded hex. |
|
||||||
|
| **Row overflow** | Remove, condition, foil | Popover: `--glass-surface-high` recipe. |
|
||||||
|
| **Footer CTAs** | Primary gradient + secondary text button | Primary: `VOCAB.ADD_TO_MY_COLLECTION` — full-width gradient button (`var(--gradient-primary-*)`, `--ember-rim-pronounced`). Secondary: `VOCAB.ADD_TO_LIST` — ghost / link style with list icon; opens picker. **Never** "Save to binder" / "Add all to collection" without vocab import. |
|
||||||
|
| **Out of scope row** | "Total value (est.)" from mock | Omit unless identify payload already includes `market_price` (convoy scope). |
|
||||||
|
|
||||||
|
Partial commit (D6): committed rows leave the list; sheet stays open until
|
||||||
|
cart empty or user dismisses. Empty cart → close sheet, resume scanning (D1).
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
**Repo tokens win.** Generator palette (slate `#1E293B`, scan-blue `#2563EB`)
|
||||||
|
is **not adopted**.
|
||||||
|
|
||||||
|
| Role | Token | Usage in this flow |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Primary accent | `--accent-ember` | Brackets, focus rings, low-confidence warnings, primary CTA gradient stop |
|
||||||
|
| Secondary accent | `--accent-flame` | Scan line, gradient stops, Review N pill |
|
||||||
|
| Gold (rare) | `--accent-gold` | Rarity badges only |
|
||||||
|
| Glass fills | `--glass-surface-{low,mid,high}` | Sheet panel / top+bottom bars / toast / peek |
|
||||||
|
| Blur | `--glass-blur-{low,mid,high}`, `--glass-saturate` | All glass overlays |
|
||||||
|
| Rims | `--rim-light-{inner,outer}`, `--ember-rim-{subtle,pronounced}` | Interactive pills and primary buttons get pronounced ember rim |
|
||||||
|
| Scrim | `--modal-scrim` | Sheet backdrop |
|
||||||
|
| Text | `--text-primary`, `--text-secondary` | All copy on glass |
|
||||||
|
| Background | `--bg-primary`, `--bg-secondary` | Row inner panels when glass-over-art fails contrast |
|
||||||
|
|
||||||
|
No hex in `.js` files. Use `var(--token)` or existing utility classes
|
||||||
|
(`.glass-panel`, gradient vars in `globals.css`).
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| Role | Font | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Display / title | System / Inter (existing) | Top bar "Scan Cards" — `font-semibold`, ~17px. No generator "clamp(3rem…)" oversized type. |
|
||||||
|
| Body | System / Inter | Row names `font-medium`; set/rarity `text-sm` `--text-secondary` |
|
||||||
|
| CTA | System / Inter | Primary button `font-semibold`; secondary `font-medium` |
|
||||||
|
|
||||||
|
### Effects + motion
|
||||||
|
|
||||||
|
| Effect | Spec |
|
||||||
|
| --- | --- |
|
||||||
|
| Transitions | 150–300ms ease on toast, peek slide, sheet open/close |
|
||||||
|
| Scan line | Looping horizontal sweep over brackets; disable loop when `prefers-reduced-motion: reduce` |
|
||||||
|
| Sheet | Slide up from bottom; scrim fade in. Focus trap while open. |
|
||||||
|
| Hover / focus | Primary controls: `--ember-rim-pronounced` on focus-visible; `cursor-pointer` on all clickables |
|
||||||
|
| Bracket pulse | Subtle ember glow on active track — `--ember-rim-subtle`, not full-panel fill |
|
||||||
|
|
||||||
|
See `docs/MOTION_SYSTEM.md` for shared motion taxonomy when implementing.
|
||||||
|
|
||||||
|
### Copy (mandatory)
|
||||||
|
|
||||||
|
Import from `lib/collection-vocabulary.js`:
|
||||||
|
|
||||||
|
| UI surface | Constant |
|
||||||
|
| --- | --- |
|
||||||
|
| Primary checkout | `VOCAB.ADD_TO_MY_COLLECTION` |
|
||||||
|
| Secondary checkout | `VOCAB.ADD_TO_LIST` |
|
||||||
|
| Bottom bar | `Review {N}` (N = cart count) |
|
||||||
|
| Toast | `{Card name} added` (success) |
|
||||||
|
|
||||||
|
Retired: "Save to binder", "Mark Owned", "Add all to collection" (use vocab
|
||||||
|
or "Add selected to My Collection" when subset selected).
|
||||||
|
|
||||||
|
### Anti-patterns (do not ship)
|
||||||
|
|
||||||
|
- Generator's light slate background + blue CTA palette on scanner chrome
|
||||||
|
- Opaque drawer replacing bottom sheet (see deprecated mock
|
||||||
|
`image-be4e787b-…` — too many actions on peek)
|
||||||
|
- Per-card Add / condition / foil on scan peek
|
||||||
|
- `backdrop-filter` on cart **thumbnail grid items** (card grid perf budget)
|
||||||
|
- Hardcoded hex in JSX; `--accent-blue` / `--accent-purple` legacy aliases
|
||||||
|
- Emoji icons (toast check must be SVG or existing glyph pattern)
|
||||||
|
- Filling overlay panels with solid ember orange (brand is rim/glow/gradient only)
|
||||||
|
- Desktop dual-theme mock layout (`image-6654f196-…`) as mobile reference
|
||||||
|
|
||||||
|
### Pre-delivery checklist
|
||||||
|
|
||||||
|
- [ ] No emojis as icons (SVG: Lucide / Heroicons)
|
||||||
|
- [ ] `cursor-pointer` on clickable elements
|
||||||
|
- [ ] Hover/focus transitions 150–300ms
|
||||||
|
- [ ] Text contrast ≥ 4.5:1 on all glass copy (use inner opaque panel if over card art)
|
||||||
|
- [ ] Keyboard focus visible (`--accent-ember` ring)
|
||||||
|
- [ ] `prefers-reduced-motion` respected (scan line, peek, sheet)
|
||||||
|
- [ ] Responsive: 375 (immersive mobile), 768, 1024 (side panel), 1440
|
||||||
|
- [ ] Flash hidden when `torch` unsupported; camera-swap `aria-label`s
|
||||||
|
- [ ] Checkout copy from `collection-vocabulary.js` only
|
||||||
|
|
||||||
|
### Token overrides vs generator
|
||||||
|
|
||||||
|
| Generator output | Locked override |
|
||||||
|
| --- | --- |
|
||||||
|
| Pattern: Bento Grid Showcase | Immersive camera + bottom-sheet checkout |
|
||||||
|
| Style: Exaggerated Minimalism | Liquid Glass (shipped) |
|
||||||
|
| Primary `#1E293B`, accent `#2563EB` | `--accent-ember`, `--accent-flame`, `--gradient-primary-*` |
|
||||||
|
| Background `#F8FAFC` | Full-bleed camera; glass overlays use `--glass-surface-*` |
|
||||||
|
| Typography: massive display type | Existing body scale; scanner chrome stays compact |
|
||||||
|
| Key effects: 10vw headings | Scan-line animation + sheet slide only |
|
||||||
|
|
||||||
|
### Conflict rule
|
||||||
|
|
||||||
|
**Repo design tokens win** when they disagree with generator output. This
|
||||||
|
lock intentionally overrides ui-ux-pro-max palette/pattern for every
|
||||||
|
scanner surface. File a convoy note + bump `design_direction.version` only
|
||||||
|
if product approves a token change.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
### Existing components to reuse
|
||||||
|
|
||||||
|
- `<GlassSurface>` (`components/ui/GlassSurface.js`) — top bar, bottom bar, scan peek, checkout sheet panel, row inner panels. Use `tint="mid"` for chrome bars, `tint="low"` for sheet body, `rim="ember-pronounced"` on Review N pill and primary CTA.
|
||||||
|
- `<Modal>` (`components/ui/Modal.js`) — back-guard confirm ("Leave scanner?"), List picker (`size="md"`), nested above checkout sheet z-index stack. Do **not** roll a custom scrim for these dialogs.
|
||||||
|
- `<Button>` (`components/ui/Button.js`) — footer primary (`variant="primary"`, `loading` during commit) and secondary (`variant="ghost"`) CTAs; back-guard actions.
|
||||||
|
- `<Input>` (`components/ui/Input.js`) — optional filter field inside List picker when `collections.length > 8`.
|
||||||
|
- `<ScannerDisambiguation>` (`components/scanner/ScannerDisambiguation.js`) — **canonical bottom-sheet shell** for checkout cart on mobile: same `fixed inset-0` scrim, `glass-panel-strong` panel, drag handle, `useFocusTrap`, Escape-to-dismiss. Checkout sheet copies this shell; do not invent a second sheet primitive.
|
||||||
|
- `<ScannerToast>` (`components/scanner/ScannerToast.js`) — success feedback after identify (`role="status"`, `aria-live="polite"`). Replace emoji glyphs with SVG check per design-direction checklist; message shape: `{Card name} added`.
|
||||||
|
- `<ScannerCountPill>` (`components/scanner/ScannerCountPill.js`) — evolve into bottom-bar **Review N** gradient pill (same `onReview` contract, `aria-label={`Review ${count} scanned cards`}`); retire the separate count row above the viewport.
|
||||||
|
- `<ReviewCardItem>` (`components/scanner/ReviewCardItem.js`) — cart row body: thumbnail, name/set, qty stepper, condition/foil overflow. Extend with leading checkbox + confidence ring; drop inline destination picker (destinations move to footer CTAs only).
|
||||||
|
- `<ScannerCamera>` (`components/scanner/ScannerCamera.js`) — viewfinder, bracket overlays, flash hook wiring, disambiguation mount point. Refactor chrome into top/bottom glass bars; keep `verificationPausedRef` plumbing.
|
||||||
|
- `<Layout>` (`components/Layout.js`) — `chrome="immersive"` on `/scanner` for `<md` only; desktop keeps sidebar.
|
||||||
|
- `useFocusTrap` / `useFocusTrapContainer` (`lib/use-focus-trap.js`) — every overlay sheet and confirm dialog; restore focus to Review N pill or back button on close.
|
||||||
|
|
||||||
|
**8 primitives** (+ 2 hooks, 1 page shell). New file `ScannerScanPeek.js` is the only net-new overlay component; it composes `<GlassSurface>` only.
|
||||||
|
|
||||||
|
### Design direction alignment
|
||||||
|
|
||||||
|
Locked `design_direction` maps directly to repo tokens — no conflicts deferred:
|
||||||
|
|
||||||
|
| Design-direction surface | Token / primitive |
|
||||||
|
| --- | --- |
|
||||||
|
| Top + bottom chrome bars | `--glass-surface-mid`, `--glass-blur-mid`, `--rim-light-*`, safe-area insets |
|
||||||
|
| Ember brackets + scan line | `--accent-flame`, `--accent-ember`, `--gradient-primary-*`; scan-line loop disabled under `prefers-reduced-motion` |
|
||||||
|
| Review N pill | `var(--gradient-primary-*)` + `--ember-rim-pronounced` on `<Button>` or styled `<GlassSurface as="button">` |
|
||||||
|
| Checkout sheet scrim | `--modal-scrim` + `blur(--glass-blur-high)` — same recipe as `<Modal>` backdrop |
|
||||||
|
| Confidence rings | green ≥85%, amber 70–84%, orange <70% via `--color-success` / `--accent-ember` tokens (not hex literals) |
|
||||||
|
| Footer CTAs | `VOCAB.ADD_TO_MY_COLLECTION`, `VOCAB.ADD_TO_LIST` from `lib/collection-vocabulary.js` |
|
||||||
|
| Peek + toast | `--glass-surface-mid` / `--glass-surface-high`; SVG icons only |
|
||||||
|
|
||||||
|
**UX override of design-direction "Edit toggles bulk-select mode":** reject Edit mode. Checkboxes are **always visible** with all rows selected by default — supermarket checkout does not hide selection behind a mode switch (Nielsen H6).
|
||||||
|
|
||||||
|
### Existing patterns to follow
|
||||||
|
|
||||||
|
- **Bottom-sheet overlay stack** — `ScannerDisambiguation.js` (scrim click dismisses only when safe; focus trap; `aria-labelledby`). Checkout sheet sits at `z-40`; disambiguation stays at `z-50` above it.
|
||||||
|
- **Cart row editing** — `ReviewCardItem.js` qty stepper + condition select pattern; overflow ⋮ menu mirrors row remove button placement.
|
||||||
|
- **Bulk selection semantics** — `useScannerQueue` `selectedCards` Set + `toggleCardSelection` / `handleBulkAction`; do not fork a parallel selection model.
|
||||||
|
- **Pause identification** — `CameraScanner.js` + `pages/scanner.js` `verificationPausedRef` pattern: `useCameraScanner` early-returns when `verificationPausedRef.current === true`; `useScannerIdentification` already sets it during disambiguation — extend to checkout sheet + List picker open.
|
||||||
|
- **Flash gating** — `useScannerFlash.js` `flashSupported` probe; conditionally render flash button (already in `ScannerCamera.js` L221–242). Flash is **rear camera only** — hide when `facingMode === 'user'`.
|
||||||
|
- **Immersive page shell** — follow `components/ProtectedRoute.js` / `pages/scanner.js` auth gate; post-login return to `/scanner` (D2) via `router.query.returnUrl` or equivalent login redirect — do not invent a new auth wrapper.
|
||||||
|
- **List display names** — `collectionDisplayName()` for picker rows; never render `'All My Cards'` literal.
|
||||||
|
- **Motion** — `docs/MOTION_SYSTEM.md` duration tokens: peek/toast `quick` (150ms), sheet `slow` (400ms); sitewide `prefers-reduced-motion` collapse applies.
|
||||||
|
- **Rules** — `.cursor/rules/ui-and-theming.mdc` (Modal primitive mandate, 44px targets, focus rings via `--accent-ember`).
|
||||||
|
|
||||||
|
### A11y constraints
|
||||||
|
|
||||||
|
Hand to `role-a11y-auditor`:
|
||||||
|
|
||||||
|
1. **WCAG 2.5.5 Target Size (AAA goal):** every tappable chrome control (back, gallery, flash, camera swap, Review N, peek tap target, row checkbox, qty ±, overflow ⋮, footer CTAs) ≥ 44×44 CSS px — use invisible padding if icon is smaller.
|
||||||
|
2. **WCAG 1.4.3 Contrast (AA):** all text on glass ≥ 4.5:1; if card-art bleeds through a row thumb, use `--bg-secondary` inner panel behind text (design-direction row spec).
|
||||||
|
3. **WCAG 2.4.3 Focus Order:** checkout sheet opens → focus moves to sheet panel (first focusable: header close/dismiss or first row checkbox via `useFocusTrap`); on close → restore to Review N pill.
|
||||||
|
4. **WCAG 2.1.1 Keyboard:** sheet dismiss via Escape; peek opens sheet on Enter/Space when focused; back button triggers confirm modal before navigation.
|
||||||
|
5. **WCAG 4.1.2 Name, Role, Value:** checkout sheet `role="dialog"` `aria-modal="true"` `aria-labelledby` → "{N} cards scanned"; List picker nested dialog gets its own label ("Choose a List").
|
||||||
|
6. **`aria-label` required:** back → "Leave scanner"; gallery → "Choose image from gallery"; flash → "Turn on flash" / "Turn off flash" + `aria-pressed`; camera swap → "Switch to front camera" / "Switch to rear camera"; Review N → `Review ${N} scanned cards`; row checkbox → `Select ${card.name}`; qty buttons → `Decrease quantity for ${card.name}` / `Increase…`; overflow → `More actions for ${card.name}`.
|
||||||
|
7. **WCAG 1.1.1 Non-text Content:** card thumbs `alt={card.name}`; decorative bracket corners `aria-hidden="true"`.
|
||||||
|
8. **WCAG 4.1.3 Status Messages:** identify success toast + post-commit success toast use `role="status"` `aria-live="polite"`; do not steal focus.
|
||||||
|
9. **WCAG 2.3.3 Animation from Interactions:** scan-line loop, peek slide, sheet slide respect `prefers-reduced-motion: reduce` (instant show/hide, no sweep).
|
||||||
|
10. **Focus trap:** checkout sheet, disambiguation sheet, back-guard confirm, List picker — all trap Tab; disambiguation above checkout maintains trap when both mounted.
|
||||||
|
11. **Confidence communicated without color alone:** ring color + text label on row (`aria-label` includes "confidence 62 percent") and header subline "{N} need review" when any row < 70%.
|
||||||
|
12. **Gallery file input:** visually hidden `<input type="file" accept="image/*">` triggered by labeled button; `aria-label` on button, not bare input.
|
||||||
|
13. **Disabled primary CTA:** when zero rows selected, `Add to My Collection` is `disabled` + `aria-disabled="true"` with visible helper "Select at least one card" (linked via `aria-describedby`).
|
||||||
|
14. **Screen reader pause announcement:** when sheet opens, `aria-hidden="true"` on camera video element OR `inert` on viewfinder chrome beneath scrim so SR does not read live camera controls under the sheet.
|
||||||
|
|
||||||
|
### Interaction patterns
|
||||||
|
|
||||||
|
| Pattern | Spec | Nielsen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Cart selection — default all selected** *(required)* | On first sheet open, `selectedCards` = all unprocessed row IDs. New card enqueued while sheet open → auto-add its ID to selection. Deselecting is per-row checkbox. Primary CTA label stays `VOCAB.ADD_TO_MY_COLLECTION`; when subset selected, append count: "Add to My Collection (3)". No "Edit mode" toggle. | H6 Recognition rather than recall |
|
||||||
|
| **Low-confidence vs disambiguation** *(required)* | **Pipeline `disambiguation` outcome → always block** via existing `ScannerDisambiguation` sheet; never enqueue until user picks. **Single-match `emit` outcome → enqueue silently** to cart regardless of confidence; surface confidence visually (ring + header subline for any row <70%). Do not re-prompt at scan time for low-confidence singles — checkout is the verification gate. | H5 Error prevention; H3 User control |
|
||||||
|
| **Scan peek** *(required)* | Slide-up 3s auto-dismiss. Container `pointer-events-none`; single child button wrapper `pointer-events-auto` with `aria-label="Open cart to review {card name}"`. No condition/foil/Add on peek. Tap opens checkout sheet. | H4 Consistency; H8 Minimalist design |
|
||||||
|
| **Checkout sheet open/close** *(required)* | Open via Review N pill or peek tap. Close via scrim tap, swipe-down on handle (nice-to-have), or header ×. On close with items remaining: resume camera (`verificationPausedRef = false`). Empty cart after commit → auto-close sheet (D1). | H1 Visibility of system status |
|
||||||
|
| **Pause identification under sheet** *(required)* | `verificationPausedRef.current = true` when checkout sheet **or** List picker **or** disambiguation is open. Camera stream stays mounted; detection/verify loop does not run. Resume on sheet close. | H1 Visibility of system status |
|
||||||
|
| **Back with unsaved cart** *(required)* | Header back with any unprocessed cart rows → `<Modal>` confirm: title "Leave scanner?", body "{N} scanned cards haven't been added yet.", primary "Keep scanning" (dismiss), secondary "Leave" (`router.back()` or `/dashboard`). No confirm when cart empty. | H3 User control and freedom |
|
||||||
|
| **Partial commit** *(required)* | Commit removes only selected rows from cart; sheet stays open with remainder (D6). Success toast `role="status"`. Primary button shows loading via `<Button loading>`. | H9 Error recovery; H1 Feedback |
|
||||||
|
| **sessionStorage cart persist** *(required)* | Serialize `scannedCards` + `selectedCards` to `sessionStorage` on change (D7); hydrate on `/scanner` mount. Tab refresh preserves cart; new tab starts empty. Clear on explicit "Leave" confirm. | H6 Recognition rather than recall |
|
||||||
|
| **Flash + camera swap** *(required)* | Flash button rendered only when `flashSupported && facingMode === 'environment'`. Camera-swap toggles `facingMode`; turning to front auto-disables torch. Swap button always visible when streaming. | H2 Match between system and real world |
|
||||||
|
| **Gallery import** *(required)* | Top-right gallery button → hidden file input; selected image runs existing identify path; on success same peek + cart enqueue as live scan. Show spinner on gallery button while identifying (`aria-busy`). | H4 Consistency |
|
||||||
|
| **Empty states** *(required)* | Cart sheet with 0 rows: should not open (Review N hidden at N=0). If last item removed in sheet → auto-close + resume camera. | H9 Error recovery |
|
||||||
|
| **Loading states** *(required)* | Camera starting: existing spinner + "Starting camera…". Commit in flight: footer CTA `loading`, row checkboxes disabled. List picker: skeleton or spinner while `collections` fetch. | H1 Visibility of system status |
|
||||||
|
| **Error states** *(required)* | Commit API failure: inline banner in sheet footer (not alert()), rows stay selected, retry enabled. Identify error: existing toast/notice path; do not enqueue. | H9 Error recovery |
|
||||||
|
| **Desktop side panel** *(required)* | `md+`: cart is persistent right panel (not bottom sheet); no immersive Layout chrome; same selection + CTA semantics; no `verificationPausedRef` pause required (user can see camera + panel simultaneously) — **optional:** still pause on mobile only. | H4 Consistency |
|
||||||
|
| **Optimistic commit** *(nice-to-have)* | Defer — show loading on CTA until API confirms; no optimistic removal (batch failures are painful). | H9 |
|
||||||
|
|
||||||
|
### Anti-patterns to avoid
|
||||||
|
|
||||||
|
- **Separate "Edit" mode hiding checkboxes** — violates supermarket-checkout mental model (H6).
|
||||||
|
- **Silent enqueue on `disambiguation` / `needsUserSelection`** — must block on picker (H5).
|
||||||
|
- **Per-card Add / condition / foil on scan peek** — design-direction anti-pattern; peek is read-only + tap-to-cart (H8).
|
||||||
|
- **Unmounting camera when sheet opens** — causes slow re-start and flash re-permission; pause via `verificationPausedRef` only (H1).
|
||||||
|
- **Auto-routing to DB on identify** — rebuild D3 reversed; enqueue only (H3).
|
||||||
|
- **"binder" / "Save to binder" / "Mark Owned" button copy** — use `VOCAB` constants; CI forbidden-strings (H4).
|
||||||
|
- **Hardcoded hex / `rgba(0,0,0,0.55)` in new chrome** — use glass tokens; migrate existing `ScannerCamera` literals in same brief (H4).
|
||||||
|
- **Emoji toast glyphs** — SVG only per design-direction checklist (H4).
|
||||||
|
- **`backdrop-filter` on cart thumbnail elements** — GPU budget; glass on row container only (H8).
|
||||||
|
- **Deck destination in checkout** — D4; no deck picker (H4).
|
||||||
|
- **Navigating away on commit** — D1 stay on camera (H3).
|
||||||
|
- **Confirm dialog via `window.confirm`** — use `<Modal>` for back-guard; matches focus-trap contract (H7 Flexibility).
|
||||||
|
- **Interactive peek controls stealing taps from viewfinder** — peek must not block scanning except its single open-cart hit target (H8).
|
||||||
|
- **localStorage for cart** — D7 specifies `sessionStorage`; do not use existing `SCANNER_SESSION_STORAGE_KEY` localStorage for cart rows (H6).
|
||||||
|
|
||||||
|
### Mobile / responsive notes
|
||||||
|
|
||||||
|
- **<768px (mobile):** `Layout` `chrome="immersive"` — hide sidebar, `TopSearchBar`, `MobileNavigation`. Camera `100dvh` full-bleed (`min-h-0` flex child). Top bar respects `env(safe-area-inset-top)`; bottom bar `env(safe-area-inset-bottom)`. Checkout = bottom sheet (max-height ~85dvh, scrollable list, sticky footer). Peek sits above bottom bar, below brackets.
|
||||||
|
- **≥768px (tablet/desktop):** Layout sidebar visible. Camera in main column; cart as **persistent right panel** (~360px) not sheet. Review N pill still opens/focuses panel if collapsed. No immersive chrome.
|
||||||
|
- **375px floor:** verify footer CTAs stack (primary full-width, secondary below) without clipping qty steppers; row thumb stays 40×56.
|
||||||
|
- **Landscape phone:** sheet max-height may need `max-h-[70dvh]` so viewfinder remains partially visible — architect to decide in brief; UX prefers partial camera peek above sheet scrim (H1).
|
||||||
|
- **Touch:** swipe-down on sheet handle to dismiss (nice-to-have); scrim tap dismisses (required). 8px minimum gap between adjacent 44px targets in bottom bar.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Locked 2026-08-14. Layout gets `chrome="immersive"` (Brief 1). Four implementer briefs; no new API routes or schema changes.
|
||||||
|
|
||||||
|
### File plan
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `components/Layout.js` | modified | `chrome="immersive"` prop — hide `MobileNavigation` + `TopSearchBar` below `md` |
|
||||||
|
| `test/components/Layout.test.js` | modified | Regression-lock immersive chrome |
|
||||||
|
| `lib/use-scanner-queue.js` | modified | Enqueue-only `handleCardScanned`; checkout commit helpers; remove auto-route |
|
||||||
|
| `lib/scanner-session.js` | modified | `SCANNER_CART_STORAGE_KEY` + load/save/clear cart in `sessionStorage` (D7) |
|
||||||
|
| `test/lib/scanner-session.test.js` | modified | Cart persistence helpers |
|
||||||
|
| `test/lib/use-scanner-queue.test.js` | new | No-network enqueue + bulk commit mocks |
|
||||||
|
| `lib/use-camera-scanner.js` | modified | `facingMode` state + `switchFacingMode` (restart stream) |
|
||||||
|
| `lib/use-scanner-identification.js` | modified | `identifyFromGalleryFile` adapter only (calls existing `tryLayer1TextIdentify`) |
|
||||||
|
| `components/scanner/ScannerCamera.js` | modified | Full-bleed glass top/bottom bars; flash/swap/gallery; peek mount point |
|
||||||
|
| `components/scanner/ScannerCountPill.js` | modified | Gradient Review {N} pill |
|
||||||
|
| `components/scanner/ScannerScanPeek.js` | new | Non-interactive 3s peek; tap opens checkout |
|
||||||
|
| `components/scanner/ScannerToast.js` | modified | SVG icons (no Unicode glyphs) |
|
||||||
|
| `components/scanner/ScannerCheckoutSheet.js` | new | Mobile bottom-sheet cart (shell from `ScannerDisambiguation`) |
|
||||||
|
| `components/scanner/ScannerReview.js` | modified | Desktop `md+` persistent side panel variant |
|
||||||
|
| `components/scanner/ReviewCardItem.js` | modified | Checkbox + confidence ring; drop row destination picker |
|
||||||
|
| `pages/scanner.js` | modified | Scanning-first orchestration; sheet state; pause ref; immersive Layout |
|
||||||
|
| `pages/login.js` | modified | Honor `returnUrl` query after login (D2) |
|
||||||
|
| `test/components/ScannerCheckoutSheet.test.js` | new | Sheet a11y + vocab + selection-disabled CTA |
|
||||||
|
|
||||||
|
**Not touched:** `pages/api/**`, `migrations/**`, `lib/scanner-card-identify.js` (identify pipeline), `components/scanner/ScannerDisambiguation.js` (reuse pattern only), `components/scanner/ScannerSetup.js` (dead path, not deleted).
|
||||||
|
|
||||||
|
### API surface
|
||||||
|
|
||||||
|
No new or modified API routes. Commit-time calls (unchanged):
|
||||||
|
|
||||||
|
| Method | Path | Request | Response | Auth | Rate limit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| POST | `/api/user-cards` | `buildScannerCardPayload(card)` | 2xx / error JSON | Bearer JWT | none (existing) |
|
||||||
|
| POST | `/api/collections/:id/cards` | `buildScannerCardPayload(card)` | 2xx / error JSON | Bearer JWT | none |
|
||||||
|
| GET | `/api/collections` | — | collection[] | Bearer JWT | none |
|
||||||
|
| POST | `/api/cards/batch-ownership` | `{ cardIds: number[] }` | `{ ownership }` | Bearer JWT | none |
|
||||||
|
|
||||||
|
Helpers live in `lib/scanner-route-api.js` (`addScannedCardToOwned`, `addScannedCardToCollection`, `fetchScannerCollections`, `fetchBatchOwnership`).
|
||||||
|
|
||||||
|
### Schema diff
|
||||||
|
|
||||||
|
None. Cart is client-side `sessionStorage` only (`SCANNER_CART_STORAGE_KEY`).
|
||||||
|
|
||||||
|
### Test plan
|
||||||
|
|
||||||
|
| Area | File | What to assert |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Cart merge defaults | `test/lib/scanner-session.test.js` (existing) | `processed: false` on new entries — keep green |
|
||||||
|
| Cart persistence | `test/lib/scanner-session.test.js` | `saveScannerCart` / `loadScannerCart` round-trip |
|
||||||
|
| No auto-route | `test/lib/use-scanner-queue.test.js` (new) | `handleCardScanned` never calls `routeScannedCardToDestination`; mock `fetch` |
|
||||||
|
| Bulk commit | `test/lib/use-scanner-queue.test.js` | `handleBulkAction('owned')` invokes `addScannedCardToOwned` per selected id |
|
||||||
|
| Layout immersive | `test/components/Layout.test.js` | Mobile nav hidden when `chrome="immersive"` |
|
||||||
|
| Checkout sheet | `test/components/ScannerCheckoutSheet.test.js` | Dialog labels, `VOCAB` CTAs, disabled primary when selection empty |
|
||||||
|
| Auth/Layout regressions | `test/lib/permission-middleware.test.js`, `test/components/Layout.test.js` | Do not weaken existing assertions |
|
||||||
|
|
||||||
|
Smoke (manual / post-merge): authenticated `/scanner` on 375px viewport — camera full-bleed, Review N opens sheet, commit to My Collection. Visual-diff may need baseline refresh if Layout chrome changes bleed to homepage (unlikely — scanner-only prop).
|
||||||
|
|
||||||
|
### Risk list
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --- | --- |
|
||||||
|
| `pages/scanner.js` was shared by all three conductor guesses | Serialized: only Brief 4 touches `scanner.js` |
|
||||||
|
| Gallery has no existing file-picker hook (boot finding) | Brief 3 adds `identifyFromGalleryFile` calling `tryLayer1TextIdentify` — no API change |
|
||||||
|
| `facingMode` swap requires stream restart | Brief 3 `stopCamera` + `startCamera` on toggle; flash forced off on front |
|
||||||
|
| `Set` not JSON-serializable | Brief 2 persists `selectedCardIds: number[]` |
|
||||||
|
| Login lacks `returnUrl` today | Brief 4 adds query param + login handler; validate path starts with `/` |
|
||||||
|
| `handleBulkAction` currently marks `processed: true` | Brief 2 removes committed rows from queue instead |
|
||||||
|
| Layout visual-diff sensitivity | Narrow prop; immersive mobile-only; no sidebar restyle |
|
||||||
|
| `verificationPausedRef` shared by disambiguation + sheet | Brief 4 `useEffect` ORs sheet/list-picker/disambiguation on mobile |
|
||||||
|
| Deck code paths remain in queue hook | UI omits deck (D4); dead `deck` branch in `handleBulkAction` harmless until cleanup convoy |
|
||||||
|
| iOS Safari no `torch` | Existing `flashSupported` probe; hide flash button |
|
||||||
|
|
||||||
|
### Decomposition
|
||||||
|
|
||||||
|
| Brief # | Title | Files | Depends on | Est. PR size |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Layout immersive chrome | `Layout.js`, `test/components/Layout.test.js` | — | ~80 LOC |
|
||||||
|
| 2 | Cart model + sessionStorage | `use-scanner-queue.js`, `scanner-session.js`, tests | — | ~200 LOC |
|
||||||
|
| 3 | Camera chrome + facing + peek | `ScannerCamera.js`, `use-camera-scanner.js`, `ScannerCountPill.js`, `ScannerScanPeek.js`, `ScannerToast.js`, `use-scanner-identification.js` | 2 | ~350 LOC |
|
||||||
|
| 4 | Page orchestration + checkout sheet | `pages/scanner.js`, `pages/login.js`, `ScannerCheckoutSheet.js`, `ScannerReview.js`, `ReviewCardItem.js`, test | 1, 2, 3 | ~380 LOC |
|
||||||
|
|
||||||
|
### Slice dependencies (multitask-ready)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
slice_dependencies:
|
||||||
|
- brief: 1
|
||||||
|
depends_on: []
|
||||||
|
files:
|
||||||
|
- components/Layout.js
|
||||||
|
- test/components/Layout.test.js
|
||||||
|
- brief: 2
|
||||||
|
depends_on: []
|
||||||
|
files:
|
||||||
|
- lib/use-scanner-queue.js
|
||||||
|
- lib/scanner-session.js
|
||||||
|
- test/lib/scanner-session.test.js
|
||||||
|
- test/lib/use-scanner-queue.test.js
|
||||||
|
- brief: 3
|
||||||
|
depends_on: [2]
|
||||||
|
files:
|
||||||
|
- components/scanner/ScannerCamera.js
|
||||||
|
- components/scanner/ScannerCountPill.js
|
||||||
|
- components/scanner/ScannerScanPeek.js
|
||||||
|
- components/scanner/ScannerToast.js
|
||||||
|
- lib/use-camera-scanner.js
|
||||||
|
- lib/use-scanner-identification.js
|
||||||
|
- brief: 4
|
||||||
|
depends_on: [1, 2, 3]
|
||||||
|
files:
|
||||||
|
- pages/scanner.js
|
||||||
|
- pages/login.js
|
||||||
|
- components/scanner/ScannerCheckoutSheet.js
|
||||||
|
- components/scanner/ScannerReview.js
|
||||||
|
- components/scanner/ReviewCardItem.js
|
||||||
|
- test/components/ScannerCheckoutSheet.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallelism:** Brief **1** and **2** can run in parallel (`depends_on: []`, disjoint files). Brief **3** starts after **2**. Brief **4** is serial after **1 + 2 + 3**. Do not dispatch Brief 3 until Brief 2 merges (cart API contract). Maximum fan-out: 2 implementers (Brief 1 ∥ Brief 2), then 1, then 1.
|
||||||
|
|
||||||
|
### Boot-the-brief check (2026-08-14)
|
||||||
|
|
||||||
|
| Check | Finding | Brief fix |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Auto-route still live | `handleCardScanned` L148–168 calls `routeScannedCardToDestination` | Brief 2 deletes block |
|
||||||
|
| No `chrome` on Layout | `Layout({ children, user, showSearch })` only | Brief 1 adds prop |
|
||||||
|
| `facingMode` hardcoded | `getUserMedia` uses `'environment'` only | Brief 3 adds toggle + restart |
|
||||||
|
| No gallery entry point | Grep found zero file-picker wiring | Brief 3 `identifyFromGalleryFile` via `tryLayer1TextIdentify` |
|
||||||
|
| Login no `returnUrl` | `login.js` always pushes `/dashboard` | Brief 4 adds safe return path |
|
||||||
|
| Cart persistence | IA said in-memory; D7 overrides to `sessionStorage` | Brief 2 separate key from `localStorage` session prefs |
|
||||||
|
| `ScannerToast` Unicode | `ICON_MAP` uses `✓` / `✗` | Brief 3 SVG replacement |
|
||||||
|
| Cross-brief props | `ScannerCamera` needs sheet orchestration | Declared in Brief 3 ↔ 4 `cross_brief_commitments` |
|
||||||
|
|
||||||
|
No new npm packages. No peer-dep conflicts. `tryLayer1TextIdentify` and `resolveIdentifyOutcome` confirmed exported from `lib/scanner-card-identify.js`.
|
||||||
146
.convoys/scanner-mobile-checkout/audits/a11y-20260814.md
Normal file
146
.convoys/scanner-mobile-checkout/audits/a11y-20260814.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# A11y audit — scanner-mobile-checkout
|
||||||
|
|
||||||
|
**Reviewer:** role-a11y-auditor
|
||||||
|
**Convoy:** `scanner-mobile-checkout`
|
||||||
|
**Surface:** uncommitted UI diff vs `origin/main` (+ untracked) — no GitHub PR
|
||||||
|
**Date:** 2026-08-14
|
||||||
|
**Model:** cursor-grok-4.5-high (`model_tier=audit`)
|
||||||
|
**Skill note:** `.cursor/skills/accessibility-audit/SKILL.md` was not present in-repo; audit follows role + convoy UX § A11y constraints (14 items) + WCAG 2.2 AA (AAA target size where convoy requires).
|
||||||
|
|
||||||
|
## Diff scope (UI)
|
||||||
|
|
||||||
|
| Path | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `components/Layout.js` | modified (`chrome="immersive"`) |
|
||||||
|
| `components/scanner/ScannerCamera.js` | modified |
|
||||||
|
| `components/scanner/ScannerCountPill.js` | modified |
|
||||||
|
| `components/scanner/ScannerToast.js` | modified |
|
||||||
|
| `components/scanner/ReviewCardItem.js` | modified |
|
||||||
|
| `components/scanner/ScannerReview.js` | modified |
|
||||||
|
| `components/scanner/ScannerCheckoutSheet.js` | **new** |
|
||||||
|
| `components/scanner/ScannerScanPeek.js` | **new** |
|
||||||
|
| `pages/scanner.js` | modified |
|
||||||
|
| `test/components/ScannerCheckoutSheet.test.js` | new (a11y-adjacent) |
|
||||||
|
|
||||||
|
Non-UI (session/hooks/login/tests) excluded from findings.
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
- Chrome controls (flash / swap / gallery / back / Review N), peek tap target, toast live region, checkout `role="dialog"` + Escape + `useFocusTrap`, and sitewide `prefers-reduced-motion` collapse are largely in good shape.
|
||||||
|
- **One severity-3 finding:** nested List-picker `<Modal>` over open checkout sheet leaves **two Escape handlers and two focus traps** active — Escape dismisses both layers (and Tab can fight). Convoy constraint 10.
|
||||||
|
- Several severity-2 gaps vs promised constraints: missing sheet close control, no `aria-describedby` helper on disabled commit CTA, camera chrome not `inert`/`aria-hidden` under sheet, native checkbox hit target unreliable at 44×44.
|
||||||
|
- **Recommendation:** request-changes until nested-overlay Escape/trap is fixed; ship sev-2 before merge if possible.
|
||||||
|
|
||||||
|
**Counts:** 7 findings (sev ≥ 3: **1**, sev < 3: **6**). Constraint checklist: **9 pass / 3 partial / 2 fail**.
|
||||||
|
|
||||||
|
## Convoy constraint checklist
|
||||||
|
|
||||||
|
| # | Constraint | Result | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 44×44 targets (2.5.5) | **Partial** | ChromeIconButton, Review N, qty ±, overflow use `minWidth/Height: 44`. Native checkbox is `w-5 h-5` with style mins — hit box often stays ~20px. |
|
||||||
|
| 2 | Contrast on glass (1.4.3) | **Pass*** | Token text colors; no instrumented contrast measure (*static review). |
|
||||||
|
| 3 | Focus order open/close (2.4.3) | **Partial** | Trap focuses first focusable in panel; restore via `useFocusTrap`. No header ×; restore may miss if opener was auto-dismissed peek. |
|
||||||
|
| 4 | Keyboard Escape / peek Enter (2.1.1) | **Partial** | Escape + button peek OK; nested Escape conflict (see A1). |
|
||||||
|
| 5 | Dialog name/role (4.1.2) | **Pass** | Checkout: `role="dialog"` `aria-modal` `aria-labelledby` → "{N} cards scanned". List picker Modal title "Choose a List". |
|
||||||
|
| 6 | Required `aria-label`s | **Pass** | Leave / gallery / flash+`aria-pressed` / swap / Review N / checkbox / qty / overflow present. |
|
||||||
|
| 7 | Non-text (1.1.1) | **Pass** | Row thumbs `alt={card.name}`; peek thumb `alt=""` under named button; decorative SVGs `aria-hidden`. |
|
||||||
|
| 8 | Status messages (4.1.3) | **Pass** | Toast `role="status"` `aria-live="polite"`; scan status `aria-live="polite"`. Peek is interactive, not a second live region (toast covers identify). |
|
||||||
|
| 9 | Reduced motion (2.3.3) | **Pass** | Peek/sheet/toast transitions + spinners covered by `styles/globals.css` `@media (prefers-reduced-motion: reduce)`. |
|
||||||
|
| 10 | Focus trap all overlays | **Fail** | Sheet traps; Modal traps; both stay active when List picker opens over checkout. |
|
||||||
|
| 11 | Confidence not color-only (1.4.1) | **Partial** | Visible `{pct}% match` + ring; checkbox `aria-label` adds confidence only when <85%; header says "Some matches…" not "{N} need review". |
|
||||||
|
| 12 | Gallery file input | **Pass** | `sr-only` input + labeled button; `aria-busy` while identifying. |
|
||||||
|
| 13 | Disabled CTA + describedby | **Fail** | `disabled` only; no helper text / `aria-describedby` / `aria-disabled`. |
|
||||||
|
| 14 | Hide camera under sheet | **Fail** | `isCheckoutOpen` only changes status copy; video + chrome not `aria-hidden`/`inert`. |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| Sev | Layer | WCAG | Surface | Issue | Fix |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| **3** | Keyboard / Focus | 2.1.1, 2.4.3 | `pages/scanner.js` + `ScannerCheckoutSheet.js` | Nested List picker Modal while checkout sheet open: both register document `Escape` + Tab traps. Escape closes **picker and sheet**. | Deactivate checkout trap + Escape while `isListPickerOpen`; or close sheet Escape only when picker closed (`if (e.key === 'Escape' && !isListPickerOpen)`). Prefer `useFocusTrap(active && !nestedOpen)`. |
|
||||||
|
| **2** | Name / Instructions | 3.3.2, 4.1.2 | `ScannerCheckoutSheet.js` `CheckoutFooter` ~L40–51 | Zero selection: primary disabled with no visible "Select at least one card" / `aria-describedby`. | Add helper `<p id="checkout-select-hint">…</p>`; `aria-describedby={selectedCount === 0 ? 'checkout-select-hint' : undefined}`; optional `aria-disabled` mirroring `disabled`. |
|
||||||
|
| **2** | Structure / AT | 1.3.2, 4.1.2 | `ScannerCamera.js` + `pages/scanner.js` | Sheet open does not set `aria-hidden`/`inert` on viewfinder chrome (constraint 14). | When `isCheckoutOpen` (mobile), set `inert` on camera root (or `aria-hidden` on `<video>` + `tabIndex={-1}` / hide chrome from AT). |
|
||||||
|
| **2** | Target size | 2.5.5 | `ReviewCardItem.js` ~L72–79 | Checkbox visual/control ~20×20 despite `minWidth/Height: 44` on `<input type="checkbox">` (unreliable). | Wrap in `<label className="… min-w-[44px] min-h-[44px] flex items-center justify-center">` or custom hit pad. |
|
||||||
|
| **2** | Keyboard / Operable | 2.1.1, 2.4.3 | `ScannerCheckoutSheet.js` ~L211–217 | No header dismiss × (convoy: close via × / Escape / scrim). Keyboard-only users depend solely on Escape. | Add `aria-label="Close cart"` button in sheet header; include in focus order as first focusable. |
|
||||||
|
| **1** | Understandable | 3.3.1 / UX | `pages/scanner.js` Leave Modal ~L152–166 | Copy uses "Stay" not "Keep scanning"; description omits `{N}` count. | `description={\`${unprocessedCount} scanned cards haven't been added yet.\`}`; primary label "Keep scanning". |
|
||||||
|
| **1** | Sensory | 1.4.1 | `ScannerCheckoutSheet.js` ~L127–130 | Low-confidence subline lacks count; checkbox confidence in name only when <85%. | Subline: `{n} need review`; include confidence in checkbox `aria-label` whenever `confidencePct != null`. |
|
||||||
|
|
||||||
|
### Severity ≥ 3 detail
|
||||||
|
|
||||||
|
#### A1 — Nested Escape / dual focus trap (sev 3)
|
||||||
|
|
||||||
|
**Scenario:** User opens checkout sheet → taps **Add to List** → List picker Modal opens. Presses **Escape** intending to close only the picker.
|
||||||
|
|
||||||
|
**Observed behavior (static):**
|
||||||
|
|
||||||
|
1. `ScannerCheckoutSheet` always calls `useFocusTrap(true)` and adds a document `keydown` Escape → `onClose` while mounted (`ScannerCheckoutSheet.js` L171–188).
|
||||||
|
2. `<Modal open={isListPickerOpen}>` independently traps focus and listens for Escape → its `onClose` (`Modal.js` L28–37; `pages/scanner.js` L168–173).
|
||||||
|
3. Checkout stays mounted (`pages/scanner.js` L131–137) while picker is open — neither trap is suspended.
|
||||||
|
|
||||||
|
Both Escape listeners fire on one keypress → picker closes **and** cart sheet closes. Tab handlers from both traps remain registered → unpredictable focus cycling (constraint 10).
|
||||||
|
|
||||||
|
**Suggested fix (minimal):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ScannerCheckoutSheet — accept activeTrap prop
|
||||||
|
const sheetRef = useFocusTrap(trapActive !== false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trapActive) return undefined;
|
||||||
|
// … Escape handler only when trapActive
|
||||||
|
}, [onClose, sheetRef, trapActive]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// pages/scanner.js
|
||||||
|
<ScannerCheckoutSheet
|
||||||
|
trapActive={!isListPickerOpen}
|
||||||
|
…
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or close List picker with Escape via Modal only, and in checkout Escape handler: `if (document.querySelector('[data-list-picker-open]')) return`.
|
||||||
|
|
||||||
|
## Layer walk (5 layers, summary)
|
||||||
|
|
||||||
|
| Layer | Verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| **1 Perceivable** | Toast/status live regions OK; confidence has text+ring; peek/toast SVG icons OK; reduced-motion via global CSS OK. |
|
||||||
|
| **2 Operable** | 44px chrome generally OK; nested Escape/trap is the blocker; missing sheet ×; checkbox target weak. |
|
||||||
|
| **3 Understandable** | Labels strong on chrome; leave-modal / empty-CTA / low-confidence copy incomplete vs convoy. |
|
||||||
|
| **4 Robust** | Dialog semantics present; dual `aria-modal` when nested; camera not inert under sheet. |
|
||||||
|
| **5 Consistency** | Matches `ScannerDisambiguation` sheet shell pattern (good); nested Modal over sheet needs same trap suspension pattern as disambiguation-above-checkout (z-50 vs z-40) — disambiguation currently unlikely while checkout open because verify pauses, but List picker is the live path. |
|
||||||
|
|
||||||
|
## Suggested diffs (priority)
|
||||||
|
|
||||||
|
1. **P0** — Suspend checkout Escape + `useFocusTrap` while List picker (or any nested Modal) is open.
|
||||||
|
2. **P1** — Disabled CTA helper + `aria-describedby`.
|
||||||
|
3. **P1** — `inert` / `aria-hidden` on camera when mobile checkout open.
|
||||||
|
4. **P1** — 44×44 checkbox hit area via label wrapper.
|
||||||
|
5. **P2** — Sheet close button; leave-modal copy + count; "{N} need review".
|
||||||
|
|
||||||
|
## Patterns to lift
|
||||||
|
|
||||||
|
- `ChromeIconButton` — good reusable 44px + `aria-label` / `aria-pressed` / `aria-busy` pattern (`ScannerCamera.js`).
|
||||||
|
- `ScannerDisambiguation` + `useFocusTrap` — correct shell to copy; extend with **`active` gated by nested overlays**.
|
||||||
|
- `ScannerToast` SVG + `role="status"` — keep; do not add a competing live region on peek.
|
||||||
|
|
||||||
|
## Automated checks
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| axe-core / CI a11y job | Not run (static role audit) |
|
||||||
|
| Manual SR | Recommend VoiceOver pass: open cart → Add to List → Escape once |
|
||||||
|
|
||||||
|
## Approval recommendation
|
||||||
|
|
||||||
|
- [ ] approve
|
||||||
|
- [x] **request-changes** — sev ≥ 3 open (nested Escape/trap)
|
||||||
|
- [ ] comment-only
|
||||||
|
|
||||||
|
Fix A1 before merge. Sev-2 items should land in the same PR or an immediate follow-up brief tagged a11y.
|
||||||
|
|
||||||
|
## Hand-off
|
||||||
|
|
||||||
|
A11y audit complete. **7 findings** (sev ≥ 3: **1**, sev < 3: **6**).
|
||||||
|
Report: `.convoys/scanner-mobile-checkout/audits/a11y-20260814.md`.
|
||||||
|
Recommend fixing sev ≥ 3 before merge.
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
# Design-System Audit — scanner-mobile-checkout
|
||||||
|
|
||||||
|
**Role:** `role-design-system-auditor`
|
||||||
|
**Surface:** uncommitted `feat/scanner-mobile-checkout` UI (`git diff origin/main` + untracked scanner UI)
|
||||||
|
**Convoy:** `.convoys/scanner-mobile-checkout.md`
|
||||||
|
**Date:** 2026-08-14
|
||||||
|
**Scope:** UI / DS only — read-only; no code changes
|
||||||
|
|
||||||
|
**Inputs reviewed**
|
||||||
|
|
||||||
|
| Source | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| Diff UI | `Layout.js`, `ScannerCamera.js`, `ScannerCountPill.js`, `ScannerToast.js`, `ReviewCardItem.js`, `ScannerReview.js`, `pages/scanner.js` |
|
||||||
|
| Untracked | `ScannerCheckoutSheet.js`, `ScannerScanPeek.js` |
|
||||||
|
| Direction lock | `design_direction` v1 + `## Design direction` in convoy |
|
||||||
|
| Tokens | `docs/DESIGN_TOKENS.md`, `styles/globals.css` |
|
||||||
|
| Primitives | `components/ui/{GlassSurface,Button,Modal,Input}.js` |
|
||||||
|
| Skill | `.cursor/skills/design-systems/SKILL.md` **not installed** in this repo; audit follows role file + prior Liquid Glass audit shape + `docs/DESIGN_TOKENS.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit summary
|
||||||
|
|
||||||
|
| Check | Status | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Direction lock (Liquid Glass + immersive camera + sheet) | ⚠️ Partial | Chrome bars + VOCAB CTAs + Modal reuse land; **L-brackets / scan-line missing**; sheet tint recipe drifts |
|
||||||
|
| Hex in JSX | ❌ | 1 literal `#ffffff` in `ReviewCardItem.js` |
|
||||||
|
| Binder / stale vocab | ✅ | No `binder` / `Mark Owned` / `Save to binder` in scoped UI |
|
||||||
|
| `backdrop-filter` on cart **thumbnails** | ✅ | Thumbs are solid image/`--bg-tertiary`; no filter on `<img>` |
|
||||||
|
| GlassSurface / Button / Modal reuse | ⚠️ Mixed | Camera chrome + peek: GlassSurface; checkout sheet/toast/rows: `glass-panel*` / hand-rolled |
|
||||||
|
| Sev ≥ 3 findings | **2** | Hex literal; missing bracket + scan-line direction lock |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maturity scoring (repo DS + this surface)
|
||||||
|
|
||||||
|
Scores 0–4. Evidence cites paths/counts.
|
||||||
|
|
||||||
|
| Axis | Score | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Tokens (T)** | **3** | Documented 3-tier glass surface / blur / rim / elevation / scrim in `docs/DESIGN_TOKENS.md` + `styles/globals.css`. Diff mostly consumes `var(--*)`. Residual: `.glass-panel` still hardcodes `blur(12px) saturate(180%)` (not `--glass-blur-*` / `--glass-saturate`); toast uses literal `blur(12px)`. |
|
||||||
|
| **Components (C)** | **3** | Kit under `components/ui/`: GlassSurface, Modal, Button, Input, SearchBar, StatCard (+ chrome). Scanner adopts GlassSurface on camera bars/icons/peek (9 call sites in `ScannerCamera.js`); Modal×2 + Button in `pages/scanner.js` / checkout footer. Gaps: toast + sheet panel + cart rows skip GlassSurface. |
|
||||||
|
| **Patterns (P)** | **2** | Locked pattern “immersive camera + bottom-sheet checkout” partially shipped (immersive Layout, sheet shell mirroring Disambiguation, peek non-interactive). **Detection chrome still filled rectangles** (info/success), not ember L-brackets + scan-line. Confidence mid-band maps to gold, not direction’s amber/ember table. |
|
||||||
|
| **Governance (G)** | **3** | Convoy `design_direction` locked 2026-08-14; `ui-and-theming.mdc` + vocab CI; `forbidden-hex-in-jsx` / forbidden stale strings. No design-systems skill package in-repo (template path missing). |
|
||||||
|
| **Adoption (A)** | **2** | This surface: GlassSurface on live chrome/peek ✅; checkout sheet + toast + rows use utility classes / ad-hoc styles ❌; Review N pill is raw `<button>` with gradient (OK shape) but skips ember-rim + dead GlassSurface import. Vocab CTAs ✅. |
|
||||||
|
|
||||||
|
**Composite:** T3 / C3 / P2 / G3 / A2
|
||||||
|
|
||||||
|
**Top leverage:** **Patterns (P)** — implement Screen 1 bracket + scan-line recipe and align checkout sheet to `GlassSurface tint="low"` + toast to GlassSurface/`--glass-surface-high` so the locked Liquid Glass pattern reads as one system, not chrome-good / body-utility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction lock
|
||||||
|
|
||||||
|
Locked fields enforced: Liquid Glass; immersive overlay + bottom sheet; ember/flame accents as rim/glow/gradient only; VOCAB CTAs; no binder; no hex; no per-card Add on peek; no `backdrop-filter` on cart thumbs; reuse GlassSurface / Modal / Button.
|
||||||
|
|
||||||
|
| Locked requirement | Diff status |
|
||||||
|
| --- | --- |
|
||||||
|
| Immersive `chrome="immersive"` mobile | ✅ `Layout.js` + `pages/scanner.js` |
|
||||||
|
| Top/bottom glass bars (`--glass-surface-mid`) | ✅ `GlassSurface tint="mid"` in `ScannerCamera.js` |
|
||||||
|
| L-brackets `--accent-flame` / `--accent-ember` + scan-line | ❌ Filled `border` boxes + `--color-info` / `--color-success` overlays; **no** scan-line / `prefers-reduced-motion` gate |
|
||||||
|
| Scan peek glass, tap-to-cart only | ✅ `ScannerScanPeek.js` |
|
||||||
|
| Toast glass-high + SVG check | ⚠️ SVG ✅; surface is `rgba(--bg-secondary-rgb)` + literal blur, not GlassSurface / `--glass-surface-high` |
|
||||||
|
| Review N gradient + `--ember-rim-pronounced` | ⚠️ Gradient ✅; **no** ember rim; unused `GlassSurface` import |
|
||||||
|
| Sheet scrim `--modal-scrim` + blur-high | ✅ `ScannerCheckoutSheet.js` styled-jsx |
|
||||||
|
| Sheet panel `--glass-surface-low` via GlassSurface | ❌ `glass-panel-strong` → surface-**high** recipe |
|
||||||
|
| Footer VOCAB + Button primary/ghost | ⚠️ VOCAB ✅; secondary uses `variant="secondary"` not `ghost` |
|
||||||
|
| Confidence rings tokenized (green / amber / orange) | ⚠️ Tokens used; mid=`--accent-gold`, low=`--color-warning` vs lock’s ember/amber table |
|
||||||
|
| No hex / no binder | ❌ one `#ffffff`; binder clear |
|
||||||
|
| Modal for leave + list picker | ✅ |
|
||||||
|
| No backdrop on thumbnails | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token audit (3-tier)
|
||||||
|
|
||||||
|
| Tier | Expectation | Diff notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Primitives | Palette / RGB triples in CSS only | No new hex in CSS from this diff. JSX `#ffffff` violates consumer rule. |
|
||||||
|
| Aliases | `--glass-surface-*`, `--glass-blur-*`, rims, scrim | Camera/scrim/peek mostly correct. Toast blur literal `12px` should be `var(--glass-blur-low)`. `.glass-panel` utility (row list) still non-token blur/saturate (pre-existing; amplified by N cart rows). |
|
||||||
|
| Components | GlassSurface / Button / Modal compose aliases | Sheet + toast + rows compose utilities/hand styles instead of primitives. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component / adoption audit (scoped surface)
|
||||||
|
|
||||||
|
| Element | Expected | Actual | Rate |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Glass chrome bars | `<GlassSurface>` | Yes (`ScannerCamera`) | High |
|
||||||
|
| Scan peek | `<GlassSurface>` | Yes | High |
|
||||||
|
| Toast | `<GlassSurface tint="high">` or equivalent | Hand-rolled | Low |
|
||||||
|
| Checkout sheet panel | `<GlassSurface tint="low">` | `glass-panel-strong` | Low |
|
||||||
|
| Cart rows | GlassSurface high **or** solid `--bg-secondary` | `.glass-panel` (blur per row) | Medium — OK container glass; GPU stack risk |
|
||||||
|
| Footer CTAs | `<Button>` | Yes | High |
|
||||||
|
| Leave / List dialogs | `<Modal>` | Yes | High |
|
||||||
|
| Review N | Button or GlassSurface + ember rim | Raw `<button>` + gradient | Medium |
|
||||||
|
|
||||||
|
**Missing primitive usage (not missing primitives):** toast + sheet should compose existing GlassSurface rather than inventing parallel glass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
Severity 0–4. Cap prioritized; sev ≥ 3 blocks merge recommendation for DS gate.
|
||||||
|
|
||||||
|
| ID | Sev | Finding | Evidence | Fix |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| DS-1 | **3** | Hardcoded hex in JSX | `ReviewCardItem.js` qty `+` button `color: '#ffffff'` | Use theme-safe token (e.g. dark-theme `--text-primary` on ember fill, or shared on-accent pattern used by `<Button variant="primary">`) |
|
||||||
|
| DS-2 | **3** | Direction lock: detection chrome ≠ L-brackets + scan-line | `ScannerCamera.js` filled rect overlays via `overlayBorderColor` (`--color-info` / `--color-success`); no bracket SVG/corners; no ember scan-line / reduced-motion | Ship Screen 1 bracket recipe (`--accent-ember` / `--accent-flame`) + gradient scan-line; disable loop under `prefers-reduced-motion` |
|
||||||
|
| DS-3 | **2** | Toast bypasses glass recipe | `ScannerToast.js`: `rgba(var(--bg-secondary-rgb), 0.9)` + `backdropFilter: 'blur(12px)'` | Wrap with `<GlassSurface tint="high" blur="low|mid" rim="subtle">`; drop literal blur px |
|
||||||
|
| DS-4 | **2** | Checkout sheet panel wrong surface tier + no GlassSurface | `ScannerCheckoutSheet.js` `glass-panel-strong` (high) for panel + sticky footer | Panel: GlassSurface `tint="low"` inside scrim; footer sticky on same recipe or solid inner; match Modal panel docs |
|
||||||
|
| DS-5 | **2** | Review N missing ember-pronounced rim; dead import | `ScannerCountPill.js` imports GlassSurface unused; no `--ember-rim-pronounced`; `text-white` utility | Apply rim via GlassSurface `as="button"` `rim="ember-pronounced"` or box-shadow token; remove dead import; avoid raw white |
|
||||||
|
| DS-6 | **2** | Confidence band colors drift from lock | `confidenceRingColor`: <70 `--color-warning`, 70–84 `--accent-gold`, else success | Align to lock: <70 `--accent-ember`, 70–84 amber/warning token, ≥85 `--color-success` |
|
||||||
|
| DS-7 | **1** | Secondary CTA variant | Checkout footer `Button variant="secondary"` | Prefer `variant="ghost"` per design direction |
|
||||||
|
| DS-8 | **1** | Leave-modal dismiss copy | `pages/scanner.js` label `"Stay"` | Direction: primary dismiss **"Keep scanning"** |
|
||||||
|
| DS-9 | **1** | Hardcoded vocab fallback | `ReviewCardItem.js` `'My Collection'` string | `VOCAB.MY_COLLECTION` |
|
||||||
|
| DS-10 | **1** | Stacked row `backdrop-filter` via `.glass-panel` | Each cart row blurs (`blur(12px)` in utility) | Prefer solid `--bg-secondary` (or GlassSurface without stacking N blurs) for row inners over thumbs — thumbs themselves are clean |
|
||||||
|
|
||||||
|
### Passes (not findings)
|
||||||
|
|
||||||
|
- No `"binder"` / `"Save to binder"` / `"Mark Owned"` in scoped UI.
|
||||||
|
- Checkout CTAs import `VOCAB.ADD_TO_MY_COLLECTION` / `VOCAB.ADD_TO_LIST`.
|
||||||
|
- List picker uses `collectionDisplayName`.
|
||||||
|
- Peek has no Add / condition / foil; single open-cart control.
|
||||||
|
- Toast icons are SVG (no emoji glyphs).
|
||||||
|
- Scrim uses `--modal-scrim` + `var(--glass-blur-high)`.
|
||||||
|
- Cart thumbnails: no `backdrop-filter`.
|
||||||
|
- Leave + List picker use `<Modal>`; commit CTAs use `<Button>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Governance notes
|
||||||
|
|
||||||
|
- Contribution path for glass: `docs/DESIGN_TOKENS.md` + `ui-and-theming.mdc` (“reach for `components/ui/` first”).
|
||||||
|
- CI: hex-in-JSX + stale vocab gates apply — **DS-1 will fail / should fail `forbidden-hex-in-jsx`**.
|
||||||
|
- Design-systems skill/templates not present under `.cursor/skills/design-systems/` — maturity G capped partly by missing shared audit tooling in-repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Do not treat DS as merge-clean until DS-1 and DS-2 are fixed.** DS-3–DS-6 are the follow-up pass that makes checkout/toast/pill match the same Liquid Glass composition as the camera chrome.
|
||||||
|
|
||||||
|
**Child tasks (sev ≥ 3):**
|
||||||
|
|
||||||
|
1. Replace `#ffffff` in `ReviewCardItem.js` (and any sibling `text-white` on detection badges if hex-gate/token policy treats them as on-accent debt).
|
||||||
|
2. Implement ember/flame L-brackets + reduced-motion-aware scan-line on the viewfinder per locked Screen 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hand-off
|
||||||
|
|
||||||
|
DS audit complete. Maturity: **T3/C3/P2/G3/A2**. Top leverage: invest in **Patterns** (brackets/scan-line + sheet/toast GlassSurface alignment). Sev ≥ 3 findings: **2**. Report: `.convoys/scanner-mobile-checkout/audits/design-system-20260814.md`.
|
||||||
39
.convoys/scanner-mobile-checkout/audits/reviewer-20260814.md
Normal file
39
.convoys/scanner-mobile-checkout/audits/reviewer-20260814.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
## Reviewer Report
|
||||||
|
|
||||||
|
| Check | Status | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Scope match | ✅ | All app changes map to briefs 1–4 `files:`; no unauthorized expansion. Convoy docs/metrics only extras. |
|
||||||
|
| Conventions | ✅ | Vocab via `VOCAB`; CSS tokens / GlassSurface; Modal for leave + list picker; login `returnUrl` hardened (`/` only, blocks `//` and `://`). |
|
||||||
|
| Security | ✅ | Shallow L1–L2: no new API routes; client cart in `sessionStorage`; returnUrl open-redirect guarded. Defer depth to security-auditor when PR exists. |
|
||||||
|
| Regression risk | medium | Scanner page rewritten (phase machine removed); auto-route deleted (intentional); Layout immersive gated by prop but TopSearchBar/MobileNavigation paths change for `/scanner`. |
|
||||||
|
| Test coverage | ⚠️ | Brief-required suites green (27/27). Commit success/error detection untested and currently wrong (see Critical). |
|
||||||
|
| Documentation | ✅ | No AGENTS.md / schema updates required for this convoy. |
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- 🔴 **Critical** (must fix before merge): Stale React state used to detect commit failure in `ScannerCheckoutContent.handleCommitOwned` and `pages/scanner.js` `handleListPick`. After `await queue.commitSelectedToOwned()` / `commitSelectedToCollection()`, `queue.scannedCards` is still the pre-commit render snapshot, so a successful commit looks like “all remaining” → false error banner; list picker never closes on success (`setIsListPickerOpen(false)` skipped). Fix: have commit helpers return `successfulIds` (or a boolean), and branch on that — do not re-read hook state immediately after `await`.
|
||||||
|
- 🟡 **Suggestion** (consider): `ReviewCardItem` still mounts the per-row destination `<select>` when `!showCheckbox`. Checkout path is fine (`showCheckbox`), but Brief 4 asked to remove inline destination pickers; dead path adds confusion and retains pre-existing `#ffffff` on that control.
|
||||||
|
- 🟡 **Suggestion** (consider): Add a focused test that commit success returns/propagates success without relying on immediate `queue.scannedCards` re-read (locks the Critical fix). Optional: login `returnUrl` honor + reject open redirect.
|
||||||
|
- 🟢 **Nice to have** (optional): Convoy markdown todos still unchecked despite implemented work — conductor/doc-writer hygiene, not a merge blocker.
|
||||||
|
|
||||||
|
### Approval recommendation
|
||||||
|
- request-changes
|
||||||
|
|
||||||
|
### AC evidence (briefs 1–4)
|
||||||
|
|
||||||
|
| Brief | AC | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Immersive hides mobile nav / TopSearchBar below md | `Layout.js` `!isImmersive` MobileNav; TopSearchBar `max-md:hidden`; tests added |
|
||||||
|
| 1 | Default chrome unchanged | `chrome = 'default'`; regression tests |
|
||||||
|
| 2 | No fetch in `handleCardScanned` | Auto-route block removed; queue test asserts no route helpers |
|
||||||
|
| 2 | sessionStorage cart | `SCANNER_CART_STORAGE_KEY` + load/save/clear + tests |
|
||||||
|
| 2 | Commit removes rows | `successfulIds` filter in `handleBulkAction` |
|
||||||
|
| 3 | Facing swap + flash rear-only | `switchFacingMode`; `showFlash` env-only; torch off on `user` |
|
||||||
|
| 3 | Peek 3s / toast SVG / gallery adapter | `ScannerScanPeek`, SVG `ToastIcon`, `identifyFromGalleryFile` |
|
||||||
|
| 4 | No Setup; returnUrl; sheet + pause; leave Modal | `pages/scanner.js` + hardened `login.js` |
|
||||||
|
| 4 | Desktop side panel | `ScannerReview` `variant="side-panel"` |
|
||||||
|
| 4 | Checkout sheet tests | header count, disabled primary, vocab labels |
|
||||||
|
|
||||||
|
### Test run
|
||||||
|
|
||||||
|
`npm run test:run -- test/components/Layout.test.js test/lib/scanner-session.test.js test/lib/use-scanner-queue.test.js test/components/ScannerCheckoutSheet.test.js` → **27/27 passed**.
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
---
|
||||||
|
convoy: scanner-mobile-checkout
|
||||||
|
brief_number: 1
|
||||||
|
depends_on: []
|
||||||
|
recommended_model: composer-2.5-fast
|
||||||
|
model_tier: fast
|
||||||
|
files:
|
||||||
|
- components/Layout.js
|
||||||
|
- test/components/Layout.test.js
|
||||||
|
cross_brief_commitments:
|
||||||
|
- brief: 4
|
||||||
|
description: |
|
||||||
|
Brief 4 passes `chrome="immersive"` on `<Layout>` from `pages/scanner.js`
|
||||||
|
for authenticated scanner sessions. This brief only adds the prop and
|
||||||
|
mobile-only hiding behavior; Brief 4 owns when it is set.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 1: Layout immersive chrome
|
||||||
|
|
||||||
|
## Goal (1 sentence)
|
||||||
|
|
||||||
|
Add a narrow `chrome="immersive"` prop to `Layout` that hides global app chrome on viewports `< md` without restyling the desktop sidebar.
|
||||||
|
|
||||||
|
## Files in scope (do not edit anything else)
|
||||||
|
|
||||||
|
- `components/Layout.js`
|
||||||
|
- `test/components/Layout.test.js`
|
||||||
|
|
||||||
|
## Conventions to follow
|
||||||
|
|
||||||
|
- Extend the existing `Layout` signature: `export default function Layout({ children, user = null, showSearch = false })` → add `chrome = 'default'`.
|
||||||
|
- Immersive is **mobile-only** (`md:hidden` / `max-md:` patterns). At `md+`, immersive behaves like `default` (sidebar + TopSearchBar unchanged per D3 desktop note).
|
||||||
|
- Hide when `chrome === 'immersive'` on `< md`:
|
||||||
|
- `<MobileNavigation />` (line ~674)
|
||||||
|
- `<TopSearchBar />` (line ~986)
|
||||||
|
- Desktop `<aside>` sidebar is already `hidden md:flex` — no change needed at `md+`; on mobile the drawer + bottom nav are the chrome to suppress.
|
||||||
|
- Main column: remove `pb-16` bottom padding when immersive (no bottom nav). Use `max-md:pb-0` on the main wrapper (`flex-1 flex flex-col pb-16 md:pb-0`).
|
||||||
|
- Outer shell: optional `max-md:p-0` when immersive so camera can be true full-bleed.
|
||||||
|
- Do **not** change sidebar styles, nav items, or command palette behavior.
|
||||||
|
- Use CSS tokens only — no new hex in JSX.
|
||||||
|
|
||||||
|
## Implementation shape (verified against `Layout.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
user = null,
|
||||||
|
showSearch = false,
|
||||||
|
chrome = 'default',
|
||||||
|
}) {
|
||||||
|
const isImmersive = chrome === 'immersive';
|
||||||
|
// ...
|
||||||
|
// MobileNavigation: {!isImmersive && <MobileNavigation ... />}
|
||||||
|
// TopSearchBar: {user && !isImmersive && <TopSearchBar ... />}
|
||||||
|
// Main wrapper className: include isImmersive && 'max-md:pb-0'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `chrome="immersive"` hides `MobileNavigation` and `TopSearchBar` below `md` breakpoint
|
||||||
|
- [ ] `chrome="immersive"` at `md+` renders identical chrome to `chrome="default"` (sidebar + TopSearchBar visible)
|
||||||
|
- [ ] `chrome="default"` (or omitted) is unchanged from current behavior
|
||||||
|
- [ ] `test/components/Layout.test.js` adds regression tests: immersive hides mobile nav landmark; default still renders Sign-in CTA when `user={null}` (existing 5 assertions preserved)
|
||||||
|
- [ ] No scope expansion (do not edit files outside `files:` above)
|
||||||
|
|
||||||
|
## Rationale (≤3 sentences)
|
||||||
|
|
||||||
|
Immersive chrome is isolated to `Layout.js` so visual-diff-sensitive sidebar code stays untouched. Mobile-only gating matches design-direction desktop exception. Brief 4 wires the prop from `/scanner` without further Layout changes.
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
---
|
||||||
|
convoy: scanner-mobile-checkout
|
||||||
|
brief_number: 2
|
||||||
|
depends_on: []
|
||||||
|
recommended_model: composer-2.5-fast
|
||||||
|
model_tier: fast
|
||||||
|
files:
|
||||||
|
- lib/use-scanner-queue.js
|
||||||
|
- lib/scanner-session.js
|
||||||
|
- test/lib/scanner-session.test.js
|
||||||
|
- test/lib/use-scanner-queue.test.js
|
||||||
|
cross_brief_commitments:
|
||||||
|
- brief: 4
|
||||||
|
description: |
|
||||||
|
Export `clearScannerCartStorage()` from `scanner-session.js`. Brief 4
|
||||||
|
calls it when the user confirms "Leave" on the back-guard modal (D1/D7).
|
||||||
|
Queue hook exposes `hydrateFromStorage` on mount — Brief 4 does not
|
||||||
|
reimplement hydration.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 2: Cart model — stop auto-route + sessionStorage
|
||||||
|
|
||||||
|
## Goal (1 sentence)
|
||||||
|
|
||||||
|
Reverse rebuild D3 auto-route: identifies enqueue locally only, auto-select new rows, and persist cart + selection in `sessionStorage` (D7).
|
||||||
|
|
||||||
|
## Files in scope (do not edit anything else)
|
||||||
|
|
||||||
|
- `lib/use-scanner-queue.js`
|
||||||
|
- `lib/scanner-session.js`
|
||||||
|
- `test/lib/scanner-session.test.js`
|
||||||
|
- `test/lib/use-scanner-queue.test.js` (new)
|
||||||
|
|
||||||
|
## Conventions to follow
|
||||||
|
|
||||||
|
- **No new API routes.** Commit paths stay `addScannedCardToOwned` / `addScannedCardToCollection` from `lib/scanner-route-api.js` (already verified).
|
||||||
|
- **Do not use** `SCANNER_SESSION_STORAGE_KEY` / `localStorage` for cart rows (UX anti-pattern). Add a separate key in `scanner-session.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const SCANNER_CART_STORAGE_KEY = 'deckhearth:scanner-cart';
|
||||||
|
```
|
||||||
|
|
||||||
|
- Serialize `selectedCards` as `number[]` (Set is not JSON-safe).
|
||||||
|
- Keep `mergeScannedCardEntry` unchanged — cart entry shape already includes `processed: false` and `confidence` from `buildScannedCardPayload`.
|
||||||
|
- `sessionDestination` param may remain on the hook signature for backward compat but **must not** trigger network I/O in `handleCardScanned`.
|
||||||
|
|
||||||
|
## Implementation shape (verified against `use-scanner-queue.js`)
|
||||||
|
|
||||||
|
**Remove auto-route** — delete the block after `setScannedCards(nextQueue)`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// DELETE lines ~148-168 (sessionDestination guard + routeScannedCardToDestination)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace `handleCardScanned` with enqueue-only:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleCardScanned = (cardData) => {
|
||||||
|
setAutoRouteError(null);
|
||||||
|
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
||||||
|
scannedCards,
|
||||||
|
cardData,
|
||||||
|
scanDefaults
|
||||||
|
);
|
||||||
|
setScannedCards(nextQueue);
|
||||||
|
setSelectedCards((prev) => new Set([...prev, cardEntry.id]));
|
||||||
|
persistCart(nextQueue, new Set([...selectedCards, cardEntry.id]));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `loadScannerCart()` / `saveScannerCart({ scannedCards, selectedCardIds })` / `clearScannerCartStorage()` in `scanner-session.js`.
|
||||||
|
|
||||||
|
Hydrate on mount in `useScannerQueue` via `useState` initializer + `useEffect` debounced save on `[scannedCards, selectedCards]` changes.
|
||||||
|
|
||||||
|
**Checkout helpers** (used by Brief 4 footer CTAs):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const commitSelectedToOwned = async () => handleBulkAction('owned');
|
||||||
|
const commitSelectedToCollection = async (collectionId) =>
|
||||||
|
handleBulkAction('collection', collectionId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Export these plus `unprocessedCount` derived helper: `scannedCards.filter(c => !c.processed).length`.
|
||||||
|
|
||||||
|
After successful `handleBulkAction`, remove committed rows from `scannedCards` entirely (not `processed: true`) per D1/D6 — cart holds only uncommitted items.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `handleCardScanned` performs zero `fetch` calls (no `routeScannedCardToDestination`)
|
||||||
|
- [ ] New enqueue auto-adds card `id` to `selectedCards`
|
||||||
|
- [ ] Cart + selection survive `sessionStorage` round-trip within a tab (`test/lib/scanner-session.test.js`)
|
||||||
|
- [ ] `test/lib/use-scanner-queue.test.js`: mock `scanner-route-api` and assert `handleCardScanned` never calls route helpers; assert `handleBulkAction('owned')` calls `addScannedCardToOwned` once per selected card
|
||||||
|
- [ ] `clearScannerCartStorage()` clears persisted state
|
||||||
|
- [ ] Existing `test/lib/scanner-session.test.js` cases still pass
|
||||||
|
- [ ] No scope expansion
|
||||||
|
|
||||||
|
## Rationale (≤3 sentences)
|
||||||
|
|
||||||
|
Cart semantics are pure client state and can land before any UI work. Separating sessionStorage from the existing `localStorage` destination prefs avoids D7/localStorage anti-pattern. Removing committed rows instead of flipping `processed` simplifies the checkout sheet list.
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
---
|
||||||
|
convoy: scanner-mobile-checkout
|
||||||
|
brief_number: 3
|
||||||
|
depends_on: [2]
|
||||||
|
recommended_model: composer-2.5-fast
|
||||||
|
model_tier: fast
|
||||||
|
files:
|
||||||
|
- components/scanner/ScannerCamera.js
|
||||||
|
- components/scanner/ScannerCountPill.js
|
||||||
|
- components/scanner/ScannerScanPeek.js
|
||||||
|
- components/scanner/ScannerToast.js
|
||||||
|
- lib/use-camera-scanner.js
|
||||||
|
- lib/use-scanner-identification.js
|
||||||
|
cross_brief_commitments:
|
||||||
|
- brief: 4
|
||||||
|
description: |
|
||||||
|
`ScannerCamera` accepts orchestration props wired by Brief 4:
|
||||||
|
`onBack`, `onOpenCheckout`, `onGalleryIdentify`, `latestPeekCard`,
|
||||||
|
`cartCount`, `isCheckoutOpen`, `verificationPausedRef`. Defaults/no-ops
|
||||||
|
keep the component renderable before Brief 4 lands. Brief 4 owns sheet
|
||||||
|
open state and back-guard modal.
|
||||||
|
- brief: 4
|
||||||
|
description: |
|
||||||
|
`useScannerIdentification` exports `identifyFromGalleryFile(file)` —
|
||||||
|
thin adapter only; does not modify `pages/api/scan/identify.js` or
|
||||||
|
`lib/scanner-card-identify.js` server contract.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brief 3: Camera chrome, facing mode, scan peek, gallery adapter
|
||||||
|
|
||||||
|
## Goal (1 sentence)
|
||||||
|
|
||||||
|
Refactor `ScannerCamera` into full-bleed immersive glass chrome (top/bottom bars, flash, camera swap, Review N pill, scan peek, gallery) and add rear/front `facingMode` support in `useCameraScanner`.
|
||||||
|
|
||||||
|
## Files in scope (do not edit anything else)
|
||||||
|
|
||||||
|
- `components/scanner/ScannerCamera.js`
|
||||||
|
- `components/scanner/ScannerCountPill.js`
|
||||||
|
- `components/scanner/ScannerScanPeek.js` (new)
|
||||||
|
- `components/scanner/ScannerToast.js`
|
||||||
|
- `lib/use-camera-scanner.js`
|
||||||
|
- `lib/use-scanner-identification.js` (gallery adapter + pause contract only)
|
||||||
|
|
||||||
|
## Conventions to follow
|
||||||
|
|
||||||
|
- Glass surfaces: `<GlassSurface tint="mid">` per UX brief; migrate hardcoded `rgba(0,0,0,0.55)` in `ScannerCamera` to tokens.
|
||||||
|
- `ScannerToast`: replace `ICON_MAP` Unicode glyphs with inline SVG check/error (design-direction checklist).
|
||||||
|
- `ScannerCountPill`: evolve to gradient **Review {N}** pill; keep `aria-label={`Review ${count} scanned cards`}`.
|
||||||
|
- Flash: `flash.flashSupported && facingMode === 'environment'` (UX spec). Hide on front camera; turning to front must call `toggleFlash` off if torch was on.
|
||||||
|
- Peek: new `ScannerScanPeek.js` — 3s auto-dismiss, `pointer-events-none` wrapper, single child button `pointer-events-auto`, tap calls `onOpenCheckout`.
|
||||||
|
- Copy: toast message `"{card.name} added"` with `role="status"`.
|
||||||
|
- Do **not** mount checkout sheet here — call `onOpenCheckout` only.
|
||||||
|
- Identification pipeline files (`scanner-card-identify.js`, API routes) are out of scope.
|
||||||
|
|
||||||
|
## `useCameraScanner` shape (verified)
|
||||||
|
|
||||||
|
Current `startCamera` hardcodes `facingMode: 'environment'` (line ~119). Extend:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef, facingMode = 'environment' }) {
|
||||||
|
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
|
||||||
|
// startCamera uses activeFacingMode in getUserMedia constraints
|
||||||
|
const switchFacingMode = useCallback(() => {
|
||||||
|
setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
|
||||||
|
}, []);
|
||||||
|
// useEffect: when activeFacingMode changes and stream exists, stopCamera + startCamera
|
||||||
|
return { /* existing */, facingMode: activeFacingMode, switchFacingMode };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`verificationPausedRef` early-return already exists at line ~91 in the tracking interval — no change needed.
|
||||||
|
|
||||||
|
## Gallery adapter (verified — no existing file-picker path)
|
||||||
|
|
||||||
|
Add to `useScannerIdentification` return object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const identifyFromGalleryFile = async (file) => {
|
||||||
|
if (!file || verificationPausedRef?.current) return;
|
||||||
|
const imageData = await readFileToImageData(file); // canvas draw; local helper in same file
|
||||||
|
const authHeaders = getScanAuthHeaders();
|
||||||
|
const result = await tryLayer1TextIdentify(imageData, authHeaders);
|
||||||
|
const outcome = resolveIdentifyOutcome(result);
|
||||||
|
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
|
||||||
|
await applyIdentifyOutcome(syntheticTracker, imageData, outcome);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `tryLayer1TextIdentify`, `resolveIdentifyOutcome` from `./scanner-card-identify.js` (already exported — verified).
|
||||||
|
|
||||||
|
## `ScannerCamera` props contract (for Brief 4)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default function ScannerCamera({
|
||||||
|
queue,
|
||||||
|
camera,
|
||||||
|
identification,
|
||||||
|
onBack,
|
||||||
|
onOpenCheckout,
|
||||||
|
onGalleryIdentify, // (file) => identification.identifyFromGalleryFile(file)
|
||||||
|
latestPeekCard, // most recent unprocessed queue entry or null
|
||||||
|
cartCount,
|
||||||
|
isCheckoutOpen,
|
||||||
|
verificationPausedRef,
|
||||||
|
}) { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove: `deckMode`, `sessionDestination`, `onStopSession`, phase-based stop button. Back button calls `onBack` (Brief 4 shows leave modal).
|
||||||
|
|
||||||
|
Layout: viewport `min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh]`; top bar with back / "Scan Cards" / gallery file input; bottom bar flash | status | Review N.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `switchFacingMode` restarts stream with toggled `facingMode`; flash hidden on `user`
|
||||||
|
- [ ] `ScannerScanPeek` auto-dismisses ~3s; tap invokes `onOpenCheckout`
|
||||||
|
- [ ] `ScannerToast` uses SVG icons, not Unicode glyphs
|
||||||
|
- [ ] `identifyFromGalleryFile` calls existing `tryLayer1TextIdentify` — no API route changes
|
||||||
|
- [ ] `ScannerCountPill` hidden when `count <= 0`; shows gradient Review pill when `count > 0`
|
||||||
|
- [ ] No hex literals added in edited `.js` files
|
||||||
|
- [ ] No scope expansion
|
||||||
|
|
||||||
|
## Rationale (≤3 sentences)
|
||||||
|
|
||||||
|
Camera chrome files are disjoint from `pages/scanner.js`, so this brief can land after the cart model without file conflicts. Gallery needs a thin hook adapter because no file-picker path exists today (boot-the-brief finding). Brief 4 wires props and pause ref when the sheet opens.
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -597,9 +597,15 @@ function NavigationContent({ user, router, onItemClick }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({ children, user = null, showSearch = false }) {
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
user = null,
|
||||||
|
showSearch = false,
|
||||||
|
chrome = 'default',
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const isImmersive = chrome === 'immersive';
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
// Command palette (redesign-v2 sub-convoy #3, 2026-06-04). Opens
|
// Command palette (redesign-v2 sub-convoy #3, 2026-06-04). Opens
|
||||||
|
|
@ -669,11 +675,15 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
||||||
// like one large page" is achieved by the hearth gradient
|
// like one large page" is achieved by the hearth gradient
|
||||||
// showing through the gap. Mobile (< 768px) keeps the current
|
// showing through the gap. Mobile (< 768px) keeps the current
|
||||||
// flush layout because the sidebar is `hidden md:flex` anyway.
|
// flush layout because the sidebar is `hidden md:flex` anyway.
|
||||||
<div className="flex h-screen md:p-4 md:gap-4">
|
<div
|
||||||
|
className={`flex h-screen md:p-4 md:gap-4${isImmersive ? ' max-md:p-0' : ''}`}
|
||||||
|
>
|
||||||
{/* Mobile Navigation - Bottom bar for mobile */}
|
{/* Mobile Navigation - Bottom bar for mobile */}
|
||||||
<MobileNavigation
|
{!isImmersive && (
|
||||||
onMenuOpen={() => setIsMobileMenuOpen(true)}
|
<MobileNavigation
|
||||||
/>
|
onMenuOpen={() => setIsMobileMenuOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
|
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
|
|
@ -971,7 +981,9 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
||||||
content). `min-w-0` prevents flex children from blowing past
|
content). `min-w-0` prevents flex children from blowing past
|
||||||
the column width when long card titles or table cells refuse
|
the column width when long card titles or table cells refuse
|
||||||
to wrap. */}
|
to wrap. */}
|
||||||
<div className="flex-1 flex flex-col pb-16 md:pb-0 md:gap-4 min-w-0">
|
<div
|
||||||
|
className={`flex-1 flex flex-col pb-16 md:pb-0 md:gap-4 min-w-0${isImmersive ? ' max-md:pb-0' : ''}`}
|
||||||
|
>
|
||||||
{/* Global top bar — redesign-v2 sub-convoy #3 (2026-06-04).
|
{/* Global top bar — redesign-v2 sub-convoy #3 (2026-06-04).
|
||||||
Rendered for ALL authenticated pages so the search +
|
Rendered for ALL authenticated pages so the search +
|
||||||
notifications + user-menu chrome is consistent everywhere
|
notifications + user-menu chrome is consistent everywhere
|
||||||
|
|
@ -983,11 +995,13 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
||||||
be removed in a cleanup convoy along with the dead
|
be removed in a cleanup convoy along with the dead
|
||||||
`searchQuery` state. */}
|
`searchQuery` state. */}
|
||||||
{user && (
|
{user && (
|
||||||
<TopSearchBar
|
<div className={isImmersive ? 'max-md:hidden' : undefined}>
|
||||||
user={user}
|
<TopSearchBar
|
||||||
notificationCount={user?.unreadNotifications ?? 0}
|
user={user}
|
||||||
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
|
notificationCount={user?.unreadNotifications ?? 0}
|
||||||
/>
|
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
|
||||||
import { formatProcessedDestination } from '../../lib/collection-vocabulary.js';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { formatProcessedDestination, VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
|
||||||
|
|
||||||
|
function confidencePercent(confidence) {
|
||||||
|
if (confidence == null || Number.isNaN(confidence)) return null;
|
||||||
|
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceRingColor(pct) {
|
||||||
|
if (pct == null) return 'var(--border)';
|
||||||
|
if (pct < 70) return 'var(--accent-ember)';
|
||||||
|
if (pct < 85) return 'var(--color-warning)';
|
||||||
|
return 'var(--color-success)';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReviewCardItem({
|
export default function ReviewCardItem({
|
||||||
card,
|
card,
|
||||||
collections,
|
collections,
|
||||||
|
|
@ -14,21 +27,70 @@ export default function ReviewCardItem({
|
||||||
onUndo,
|
onUndo,
|
||||||
isAdding = false,
|
isAdding = false,
|
||||||
sessionDestination,
|
sessionDestination,
|
||||||
|
selected = false,
|
||||||
|
onToggleSelect,
|
||||||
|
showCheckbox = false,
|
||||||
|
confidence,
|
||||||
}) {
|
}) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
const resolvedConfidence = confidence ?? card.confidence;
|
||||||
|
const confidencePct = confidencePercent(resolvedConfidence);
|
||||||
const destinationLabel = card.processedAction
|
const destinationLabel = card.processedAction
|
||||||
? formatProcessedDestination(card.processedAction)
|
? formatProcessedDestination(card.processedAction)
|
||||||
: sessionDestination?.label ?? 'My Collection';
|
: sessionDestination?.label ?? VOCAB.MY_COLLECTION;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return undefined;
|
||||||
|
|
||||||
|
const handlePointerDown = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
|
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const checkboxAriaLabel =
|
||||||
|
confidencePct != null
|
||||||
|
? `Select ${card.name}, ${confidencePct}% confidence`
|
||||||
|
: `Select ${card.name}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
|
className={`glass-panel rounded-xl p-3 mb-3 transition-opacity duration-200 ${card.processed ? 'opacity-60' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
borderLeft: !card.processed ? '3px solid var(--accent-ember)' : '3px solid transparent',
|
borderLeft: !card.processed && !showCheckbox ? '3px solid var(--accent-ember)' : '3px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Thumbnail */}
|
{showCheckbox && !card.processed && (
|
||||||
<div className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden">
|
<label
|
||||||
|
className="flex-shrink-0 flex items-center justify-center"
|
||||||
|
style={{ minWidth: 44, minHeight: 44 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggleSelect}
|
||||||
|
className="w-5 h-5 rounded"
|
||||||
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
|
aria-label={checkboxAriaLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-10 h-14 rounded-lg overflow-hidden"
|
||||||
|
style={{
|
||||||
|
boxShadow: showCheckbox
|
||||||
|
? `0 0 0 2px ${confidenceRingColor(confidencePct)}`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{card.image_url ? (
|
{card.image_url ? (
|
||||||
<img
|
<img
|
||||||
src={card.image_url}
|
src={card.image_url}
|
||||||
|
|
@ -48,22 +110,21 @@ export default function ReviewCardItem({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card info + controls */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{card.processed ? (
|
{!showCheckbox && card.processed ? (
|
||||||
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
<svg className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-gold)' }} fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : !showCheckbox && !card.processed ? (
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: 'var(--accent-flame)' }}
|
style={{ backgroundColor: 'var(--accent-flame)' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold text-sm truncate"
|
className="font-semibold text-sm truncate"
|
||||||
style={{ color: 'var(--text-primary)' }}
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
|
@ -74,10 +135,80 @@ export default function ReviewCardItem({
|
||||||
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
|
{[card.set, card.cardNumber].filter(Boolean).join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
|
{showCheckbox && confidencePct != null && (
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{confidencePct}% match
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remove button (unprocessed only) */}
|
{showCheckbox && !card.processed ? (
|
||||||
{!card.processed && (
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors duration-150 hover:opacity-80"
|
||||||
|
style={{ color: 'var(--text-secondary)', minWidth: 44, minHeight: 44 }}
|
||||||
|
aria-label={`More actions for ${card.name}`}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full mt-1 z-10 min-w-[10rem] rounded-xl py-1 glass-panel-strong shadow-lg"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
|
||||||
|
style={{ color: 'var(--text-primary)', minHeight: 44 }}
|
||||||
|
onClick={() => {
|
||||||
|
onRemove();
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div className="px-3 py-2" role="none">
|
||||||
|
<label className="text-xs block mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Condition
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={card.condition || 'NM'}
|
||||||
|
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
|
||||||
|
className="w-full px-2 py-1.5 rounded-lg text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minHeight: 44,
|
||||||
|
}}
|
||||||
|
aria-label={`Condition for ${card.name}`}
|
||||||
|
>
|
||||||
|
{CONDITION_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:opacity-80"
|
||||||
|
style={{ color: 'var(--text-primary)', minHeight: 44 }}
|
||||||
|
onClick={() => onUpdateMetadata({ isFoil: !card.isFoil })}
|
||||||
|
>
|
||||||
|
{card.isFoil ? 'Mark not foil' : 'Mark foil'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : !card.processed ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
|
|
@ -89,7 +220,7 @@ export default function ReviewCardItem({
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{card.processed ? (
|
{card.processed ? (
|
||||||
|
|
@ -111,96 +242,94 @@ export default function ReviewCardItem({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Metadata summary line */}
|
{!showCheckbox && (
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
|
{card.condition || 'NM'} · {card.isFoil ? 'Foil' : 'Not Foil'} · Qty: {card.quantity || 1}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inline edit controls */}
|
{!showCheckbox && (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 mt-2">
|
<div className="flex flex-col sm:flex-row gap-2 mt-2">
|
||||||
{/* Condition */}
|
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
<span className="sr-only">Condition for {card.name}</span>
|
||||||
<span className="sr-only">Condition for {card.name}</span>
|
<select
|
||||||
<select
|
value={card.condition || 'NM'}
|
||||||
value={card.condition || 'NM'}
|
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
|
||||||
onChange={(e) => onUpdateMetadata({ condition: e.target.value })}
|
className="px-2 py-1.5 rounded-lg text-xs"
|
||||||
className="px-2 py-1.5 rounded-lg text-xs"
|
style={{
|
||||||
style={{
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
borderColor: 'var(--border)',
|
||||||
borderColor: 'var(--border)',
|
color: 'var(--text-primary)',
|
||||||
color: 'var(--text-primary)',
|
border: '1px solid var(--border)',
|
||||||
border: '1px solid var(--border)',
|
minHeight: '44px',
|
||||||
minHeight: '44px',
|
}}
|
||||||
}}
|
aria-label={`Condition for ${card.name}`}
|
||||||
aria-label={`Condition for ${card.name}`}
|
>
|
||||||
>
|
{CONDITION_OPTIONS.map((opt) => (
|
||||||
{CONDITION_OPTIONS.map((opt) => (
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</select>
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Foil toggle */}
|
<label
|
||||||
<label
|
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
|
||||||
className="inline-flex items-center gap-1.5 text-xs cursor-pointer"
|
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
|
||||||
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(card.isFoil)}
|
|
||||||
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
|
|
||||||
className="w-4 h-4 rounded"
|
|
||||||
style={{ accentColor: 'var(--accent-ember)' }}
|
|
||||||
/>
|
|
||||||
<span>Foil</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Quantity ± */}
|
|
||||||
<div className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDecrement}
|
|
||||||
disabled={isAdding || (card.quantity || 1) <= 1}
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--bg-secondary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
minWidth: '44px',
|
|
||||||
minHeight: '44px',
|
|
||||||
}}
|
|
||||||
aria-label={`Decrease quantity of ${card.name}`}
|
|
||||||
>
|
>
|
||||||
−
|
<input
|
||||||
</button>
|
type="checkbox"
|
||||||
<span
|
checked={Boolean(card.isFoil)}
|
||||||
className="min-w-[1.5rem] text-center text-sm font-medium"
|
onChange={(e) => onUpdateMetadata({ isFoil: e.target.checked })}
|
||||||
style={{ color: 'var(--text-primary)' }}
|
className="w-4 h-4 rounded"
|
||||||
aria-live="polite"
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||||||
aria-label={`Quantity: ${card.quantity || 1}`}
|
/>
|
||||||
>
|
<span>Foil</span>
|
||||||
{card.quantity || 1}
|
</label>
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onIncrement}
|
|
||||||
disabled={isAdding}
|
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--accent-ember)',
|
|
||||||
color: '#ffffff',
|
|
||||||
minWidth: '44px',
|
|
||||||
minHeight: '44px',
|
|
||||||
}}
|
|
||||||
aria-label={`Increase quantity of ${card.name}`}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-1 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrement}
|
||||||
|
disabled={isAdding || (card.quantity || 1) <= 1}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
aria-label={`Decrease quantity of ${card.name}`}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="min-w-[1.5rem] text-center text-sm font-medium"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={`Quantity: ${card.quantity || 1}`}
|
||||||
|
>
|
||||||
|
{card.quantity || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrement}
|
||||||
|
disabled={isAdding}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-opacity duration-150 hover:opacity-80 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--accent-ember)',
|
||||||
|
color: 'var(--text-primary-dark)',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
}}
|
||||||
|
aria-label={`Increase quantity of ${card.name}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination override */}
|
{!showCheckbox && collections?.length > 0 && (
|
||||||
{(collections.length > 0 || decks.length > 0) && (
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<select
|
<select
|
||||||
value={card.destinationOverride || ''}
|
value={card.destinationOverride || ''}
|
||||||
|
|
@ -229,7 +358,7 @@ export default function ReviewCardItem({
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{decks.map((d) => (
|
{decks?.map((d) => (
|
||||||
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
|
<option key={`deck-${d.id}`} value={`deck:${d.id}`}>
|
||||||
{d.name}
|
{d.name}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,167 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
import ScannerToast from './ScannerToast.js';
|
import ScannerToast from './ScannerToast.js';
|
||||||
import ScannerCountPill from './ScannerCountPill.js';
|
import ScannerCountPill from './ScannerCountPill.js';
|
||||||
import DeckModeIndicator from './DeckModeIndicator.js';
|
import ScannerScanPeek from './ScannerScanPeek.js';
|
||||||
import ScannerDisambiguation from './ScannerDisambiguation.js';
|
import ScannerDisambiguation from './ScannerDisambiguation.js';
|
||||||
import { useScannerSound } from '../../lib/use-scanner-sound.js';
|
import { useScannerSound } from '../../lib/use-scanner-sound.js';
|
||||||
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
|
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
|
||||||
|
|
||||||
const TOAST_DURATION_MS = 2500;
|
const TOAST_DURATION_MS = 2500;
|
||||||
|
|
||||||
function overlayBorderColor(status) {
|
function DetectionFrame({ card, videoMetrics }) {
|
||||||
if (status === 'scanned') return 'var(--color-info, #3B82F6)';
|
return (
|
||||||
return 'var(--color-success, #10B981)';
|
<div
|
||||||
|
className="absolute pointer-events-none scan-frame"
|
||||||
|
style={{
|
||||||
|
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
|
||||||
|
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
|
||||||
|
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
|
||||||
|
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="scan-bracket scan-bracket-tl" aria-hidden="true" />
|
||||||
|
<span className="scan-bracket scan-bracket-tr" aria-hidden="true" />
|
||||||
|
<span className="scan-bracket scan-bracket-bl" aria-hidden="true" />
|
||||||
|
<span className="scan-bracket scan-bracket-br" aria-hidden="true" />
|
||||||
|
<span className="scan-line" aria-hidden="true" />
|
||||||
|
<style jsx>{`
|
||||||
|
.scan-bracket {
|
||||||
|
position: absolute;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-color: var(--accent-ember);
|
||||||
|
border-style: solid;
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent-flame) 55%, transparent));
|
||||||
|
}
|
||||||
|
.scan-bracket-tl { top: 0; left: 0; border-width: 3px 0 0 3px; }
|
||||||
|
.scan-bracket-tr { top: 0; right: 0; border-width: 3px 3px 0 0; }
|
||||||
|
.scan-bracket-bl { bottom: 0; left: 0; border-width: 0 0 3px 3px; }
|
||||||
|
.scan-bracket-br { bottom: 0; right: 0; border-width: 0 3px 3px 0; }
|
||||||
|
.scan-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 8%;
|
||||||
|
right: 8%;
|
||||||
|
height: 2px;
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--accent-ember),
|
||||||
|
var(--accent-flame),
|
||||||
|
var(--accent-ember),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--accent-flame) 70%, transparent);
|
||||||
|
animation: scan-sweep 1.6s var(--motion-ease-linear, linear) infinite;
|
||||||
|
}
|
||||||
|
@keyframes scan-sweep {
|
||||||
|
0% { top: 8%; opacity: 0.35; }
|
||||||
|
50% { top: 88%; opacity: 1; }
|
||||||
|
100% { top: 8%; opacity: 0.35; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scan-line {
|
||||||
|
animation: none;
|
||||||
|
top: 50%;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function overlayGlowColor(status) {
|
function ChromeIconButton({
|
||||||
if (status === 'scanned') return 'rgba(59, 130, 246, 0.31)';
|
onClick,
|
||||||
return 'rgba(16, 185, 129, 0.31)';
|
label,
|
||||||
|
pressed,
|
||||||
|
busy,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-11 h-11 rounded-full flex items-center justify-center transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${className}`}
|
||||||
|
style={{
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
'--tw-ring-color': 'var(--accent-ember)',
|
||||||
|
'--tw-ring-offset-color': 'transparent',
|
||||||
|
}}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={pressed}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<GlassSurface
|
||||||
|
as="span"
|
||||||
|
tint="mid"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
className="w-full h-full rounded-full flex items-center justify-center"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GlassSurface>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScannerCamera({
|
export default function ScannerCamera({
|
||||||
queue,
|
queue,
|
||||||
camera,
|
camera,
|
||||||
identification,
|
identification,
|
||||||
deckMode,
|
onBack = () => {},
|
||||||
onReview,
|
onOpenCheckout = () => {},
|
||||||
onStopSession,
|
onGalleryIdentify,
|
||||||
sessionDestination,
|
latestPeekCard = null,
|
||||||
|
cartCount,
|
||||||
|
isCheckoutOpen = false,
|
||||||
|
verificationPausedRef,
|
||||||
}) {
|
}) {
|
||||||
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
|
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
|
||||||
|
const [galleryBusy, setGalleryBusy] = useState(false);
|
||||||
const toastTimerRef = useRef(null);
|
const toastTimerRef = useRef(null);
|
||||||
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
|
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
|
||||||
|
const galleryInputRef = useRef(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoRef,
|
videoRef,
|
||||||
canvasRef,
|
canvasRef,
|
||||||
detectionCanvasRef,
|
detectionCanvasRef,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isDetecting,
|
|
||||||
trackedCards,
|
trackedCards,
|
||||||
videoMetrics,
|
videoMetrics,
|
||||||
startCamera,
|
startCamera,
|
||||||
stopCamera,
|
|
||||||
streamRef,
|
streamRef,
|
||||||
|
facingMode,
|
||||||
|
switchFacingMode,
|
||||||
} = camera;
|
} = camera;
|
||||||
|
|
||||||
const sound = useScannerSound();
|
const sound = useScannerSound();
|
||||||
// Requires useCameraScanner to expose streamRef — gracefully degrades to flash-unsupported if absent
|
|
||||||
const flash = useScannerFlash(streamRef);
|
const flash = useScannerFlash(streamRef);
|
||||||
|
|
||||||
// Auto-start camera on mount
|
const resolvedCartCount = cartCount ?? queue.scannedCards?.length ?? 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startCamera();
|
startCamera();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// React to newly scanned cards
|
|
||||||
const currentCount = queue.scannedCards?.length ?? 0;
|
const currentCount = queue.scannedCards?.length ?? 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentCount > prevCountRef.current) {
|
if (currentCount > prevCountRef.current) {
|
||||||
const newest = queue.scannedCards[0];
|
const newest = queue.scannedCards[0];
|
||||||
const cardName = newest?.name || newest?.card?.name || 'Card scanned';
|
const cardName = newest?.name || newest?.card?.name || 'Card';
|
||||||
|
|
||||||
navigator.vibrate?.(50);
|
navigator.vibrate?.(50);
|
||||||
sound.playSuccess();
|
sound.playSuccess();
|
||||||
|
|
||||||
clearTimeout(toastTimerRef.current);
|
clearTimeout(toastTimerRef.current);
|
||||||
setToast({ message: cardName, visible: true, type: 'success' });
|
setToast({ message: `${cardName} added`, visible: true, type: 'success' });
|
||||||
toastTimerRef.current = setTimeout(() => {
|
toastTimerRef.current = setTimeout(() => {
|
||||||
setToast((t) => ({ ...t, visible: false }));
|
setToast((t) => ({ ...t, visible: false }));
|
||||||
}, TOAST_DURATION_MS);
|
}, TOAST_DURATION_MS);
|
||||||
|
|
@ -77,29 +173,60 @@ export default function ScannerCamera({
|
||||||
return () => clearTimeout(toastTimerRef.current);
|
return () => clearTimeout(toastTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
useEffect(() => {
|
||||||
stopCamera();
|
if (facingMode === 'user' && flash.flashOn) {
|
||||||
onStopSession?.();
|
flash.toggleFlash();
|
||||||
}, [stopCamera, onStopSession]);
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to facing mode changes
|
||||||
|
}, [facingMode]);
|
||||||
|
|
||||||
|
const handleGalleryClick = useCallback(() => {
|
||||||
|
galleryInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGalleryChange = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const identify =
|
||||||
|
onGalleryIdentify ||
|
||||||
|
identification.identifyFromGalleryFile?.bind(identification);
|
||||||
|
|
||||||
|
if (!identify) return;
|
||||||
|
|
||||||
|
setGalleryBusy(true);
|
||||||
|
try {
|
||||||
|
await identify(file);
|
||||||
|
} finally {
|
||||||
|
setGalleryBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[identification, onGalleryIdentify]
|
||||||
|
);
|
||||||
|
|
||||||
const foundCards = trackedCards.filter(
|
const foundCards = trackedCards.filter(
|
||||||
(c) => c.status === 'confirmed' || c.status === 'scanned'
|
(c) => c.status === 'confirmed' || c.status === 'scanned'
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
|
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
|
||||||
|
const showFlash = flash.flashSupported && facingMode === 'environment';
|
||||||
|
const scanStatus = isCheckoutOpen
|
||||||
|
? 'Checkout open'
|
||||||
|
: isStreaming
|
||||||
|
? 'Scanning…'
|
||||||
|
: 'Starting camera…';
|
||||||
|
|
||||||
|
const switchCameraLabel =
|
||||||
|
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full gap-3">
|
<div className="relative w-full min-h-[100dvh] max-md:rounded-none max-md:min-h-[100dvh] overflow-hidden">
|
||||||
{/* Camera viewport */}
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full overflow-hidden rounded-2xl flex-1"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
minHeight: '60vh',
|
|
||||||
aspectRatio: '4 / 3',
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Video */}
|
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
|
@ -110,140 +237,43 @@ export default function ScannerCamera({
|
||||||
aria-label="Card scanner camera feed"
|
aria-label="Card scanner camera feed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bounding box overlays */}
|
|
||||||
{isStreaming &&
|
{isStreaming &&
|
||||||
hasMetrics &&
|
hasMetrics &&
|
||||||
foundCards.map((card) => (
|
foundCards.map((card) => (
|
||||||
<div
|
<DetectionFrame key={card.id} card={card} videoMetrics={videoMetrics} />
|
||||||
key={card.id}
|
|
||||||
className="absolute rounded-lg transition-all duration-200 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: `${(card.bounds.x / videoMetrics.width) * 100}%`,
|
|
||||||
top: `${(card.bounds.y / videoMetrics.height) * 100}%`,
|
|
||||||
width: `${(card.bounds.width / videoMetrics.width) * 100}%`,
|
|
||||||
height: `${(card.bounds.height / videoMetrics.height) * 100}%`,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: overlayBorderColor(card.status),
|
|
||||||
boxShadow: `0 0 15px ${overlayGlowColor(card.status)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute -top-7 left-0 px-2.5 py-0.5 rounded-full text-xs font-bold text-white shadow-md whitespace-nowrap"
|
|
||||||
style={{ backgroundColor: overlayBorderColor(card.status) }}
|
|
||||||
>
|
|
||||||
{card.status === 'scanned' ? 'Scanned' : 'Found'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* LIVE indicator */}
|
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<div className="absolute top-3 right-3 z-10">
|
<div className="absolute top-3 right-3 z-10 md:hidden">
|
||||||
<div
|
<GlassSurface
|
||||||
|
tint="mid"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold shadow-lg"
|
||||||
style={{
|
style={{ color: 'var(--text-primary)' }}
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
WebkitBackdropFilter: 'blur(8px)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full animate-pulse"
|
className="w-1.5 h-1.5 rounded-full animate-pulse"
|
||||||
style={{ backgroundColor: 'var(--color-error, #EF4444)' }}
|
style={{ backgroundColor: 'var(--color-error)' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
LIVE
|
LIVE
|
||||||
</div>
|
</GlassSurface>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toast */}
|
|
||||||
<ScannerToast
|
<ScannerToast
|
||||||
message={toast.message}
|
message={toast.message}
|
||||||
visible={toast.visible}
|
visible={toast.visible}
|
||||||
type={toast.type}
|
type={toast.type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom controls */}
|
<ScannerScanPeek
|
||||||
{isStreaming && (
|
key={latestPeekCard?.id ?? latestPeekCard?.name ?? 'peek'}
|
||||||
<div className="absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-4 px-4">
|
card={latestPeekCard}
|
||||||
{/* Sound toggle */}
|
onOpenCheckout={onOpenCheckout}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => sound.setEnabled((v) => !v)}
|
|
||||||
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
WebkitBackdropFilter: 'blur(8px)',
|
|
||||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
aria-label={sound.enabled ? 'Mute scan sounds' : 'Unmute scan sounds'}
|
|
||||||
aria-pressed={sound.enabled}
|
|
||||||
>
|
|
||||||
{sound.enabled ? (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
||||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
||||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
||||||
<line x1="23" y1="9" x2="17" y2="15" />
|
|
||||||
<line x1="17" y1="9" x2="23" y2="15" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Stop button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleStop}
|
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center shadow-2xl transition-transform active:scale-95"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
|
||||||
border: '3px solid rgba(255, 255, 255, 0.3)',
|
|
||||||
}}
|
|
||||||
aria-label="Stop scanning"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-sm"
|
|
||||||
style={{ backgroundColor: '#fff' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Flash toggle */}
|
|
||||||
{flash.flashSupported && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={flash.toggleFlash}
|
|
||||||
className="w-11 h-11 rounded-full flex items-center justify-center shadow-lg transition-transform active:scale-95"
|
|
||||||
style={{
|
|
||||||
backgroundColor: flash.flashOn
|
|
||||||
? 'rgba(255, 171, 64, 0.85)'
|
|
||||||
: 'rgba(0, 0, 0, 0.55)',
|
|
||||||
backdropFilter: 'blur(8px)',
|
|
||||||
WebkitBackdropFilter: 'blur(8px)',
|
|
||||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
aria-label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
|
|
||||||
aria-pressed={flash.flashOn}
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state (before stream starts) */}
|
|
||||||
{!isStreaming && (
|
{!isStreaming && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
|
@ -263,23 +293,168 @@ export default function ScannerCamera({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden canvases for detection */}
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 z-30 px-3 pb-2"
|
||||||
|
style={{ paddingTop: 'calc(0.75rem + env(safe-area-inset-top, 0px))' }}
|
||||||
|
>
|
||||||
|
<GlassSurface
|
||||||
|
tint="mid"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
|
||||||
|
>
|
||||||
|
<ChromeIconButton onClick={onBack} label="Leave scanner">
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
<path d="M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</ChromeIconButton>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="text-base font-semibold truncate"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
Scan Cards
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ChromeIconButton
|
||||||
|
onClick={handleGalleryClick}
|
||||||
|
label="Choose image from gallery"
|
||||||
|
busy={galleryBusy}
|
||||||
|
>
|
||||||
|
{galleryBusy ? (
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-t-transparent animate-spin"
|
||||||
|
style={{ borderColor: 'var(--accent-ember)', borderTopColor: 'transparent' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</ChromeIconButton>
|
||||||
|
</GlassSurface>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
onChange={handleGalleryChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isStreaming && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 z-30 px-3 pt-2"
|
||||||
|
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))' }}
|
||||||
|
>
|
||||||
|
<GlassSurface
|
||||||
|
tint="mid"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
className="flex items-center justify-between gap-2 rounded-2xl px-2 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showFlash ? (
|
||||||
|
<ChromeIconButton
|
||||||
|
onClick={flash.toggleFlash}
|
||||||
|
label={flash.flashOn ? 'Turn off flash' : 'Turn on flash'}
|
||||||
|
pressed={flash.flashOn}
|
||||||
|
className={flash.flashOn ? 'ring-2' : ''}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||||
|
</svg>
|
||||||
|
</ChromeIconButton>
|
||||||
|
) : (
|
||||||
|
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
|
||||||
|
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
<path d="m16 9 3 3-3 3" />
|
||||||
|
<path d="m8 15-3-3 3-3" />
|
||||||
|
</svg>
|
||||||
|
</ChromeIconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFlash && (
|
||||||
|
<ChromeIconButton onClick={switchFacingMode} label={switchCameraLabel}>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
|
||||||
|
<path d="M13 5h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-6" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
<path d="m16 9 3 3-3 3" />
|
||||||
|
<path d="m8 15-3-3 3-3" />
|
||||||
|
</svg>
|
||||||
|
</ChromeIconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium truncate text-center flex-1 px-1"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{scanStatus}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ScannerCountPill
|
||||||
|
count={resolvedCartCount}
|
||||||
|
onOpenCheckout={onOpenCheckout}
|
||||||
|
/>
|
||||||
|
</GlassSurface>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
|
<canvas ref={canvasRef} className="hidden" aria-hidden="true" />
|
||||||
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
|
<canvas ref={detectionCanvasRef} className="hidden" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Count pill */}
|
|
||||||
<ScannerCountPill count={currentCount} onReview={onReview} />
|
|
||||||
|
|
||||||
{/* Deck mode progress */}
|
|
||||||
{deckMode && (
|
|
||||||
<DeckModeIndicator
|
|
||||||
current={currentCount}
|
|
||||||
target={deckMode.targetSize}
|
|
||||||
game={deckMode.game}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Disambiguation overlay */}
|
|
||||||
{identification.disambiguation && (
|
{identification.disambiguation && (
|
||||||
<ScannerDisambiguation
|
<ScannerDisambiguation
|
||||||
disambiguation={identification.disambiguation}
|
disambiguation={identification.disambiguation}
|
||||||
|
|
|
||||||
274
components/scanner/ScannerCheckoutSheet.js
Normal file
274
components/scanner/ScannerCheckoutSheet.js
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useFocusTrap } from '../../lib/use-focus-trap';
|
||||||
|
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
|
import { Button } from '../ui';
|
||||||
|
import ReviewCardItem from './ReviewCardItem';
|
||||||
|
|
||||||
|
function confidencePercent(confidence) {
|
||||||
|
if (confidence == null || Number.isNaN(confidence)) return null;
|
||||||
|
return confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckoutFooter({
|
||||||
|
selectedCount,
|
||||||
|
isProcessing,
|
||||||
|
commitError,
|
||||||
|
onCommitOwned,
|
||||||
|
onOpenListPicker,
|
||||||
|
}) {
|
||||||
|
const selectHintId = 'checkout-select-hint';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-4 sm:px-6 py-4 glass-panel-strong"
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{commitError && (
|
||||||
|
<div
|
||||||
|
className="mb-3 px-3 py-2 rounded-lg text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{commitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCount === 0 && (
|
||||||
|
<p
|
||||||
|
id={selectHintId}
|
||||||
|
className="mb-2 text-sm"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Select at least one card
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCommitOwned}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
loading={isProcessing}
|
||||||
|
aria-describedby={selectedCount === 0 ? selectHintId : undefined}
|
||||||
|
>
|
||||||
|
{selectedCount > 0
|
||||||
|
? `${VOCAB.ADD_TO_MY_COLLECTION} (${selectedCount})`
|
||||||
|
: VOCAB.ADD_TO_MY_COLLECTION}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
className="w-full mt-2"
|
||||||
|
onClick={onOpenListPicker}
|
||||||
|
disabled={selectedCount === 0 || isProcessing}
|
||||||
|
>
|
||||||
|
{VOCAB.ADD_TO_LIST}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScannerCheckoutContent({
|
||||||
|
queue,
|
||||||
|
onOpenListPicker,
|
||||||
|
onEmpty,
|
||||||
|
headerId = 'checkout-sheet-title',
|
||||||
|
}) {
|
||||||
|
const [commitError, setCommitError] = useState(null);
|
||||||
|
const initialSelectionDoneRef = useRef(false);
|
||||||
|
|
||||||
|
const cartCards = queue.scannedCards.filter((card) => !card.processed);
|
||||||
|
const selectedCount = cartCards.filter((card) => queue.selectedCards.has(card.id)).length;
|
||||||
|
const lowConfidenceCount = cartCards.filter((card) => {
|
||||||
|
const pct = confidencePercent(card.confidence);
|
||||||
|
return pct != null && pct < 70;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialSelectionDoneRef.current) return;
|
||||||
|
cartCards.forEach((card) => {
|
||||||
|
if (!queue.selectedCards.has(card.id)) {
|
||||||
|
queue.toggleCardSelection(card.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
initialSelectionDoneRef.current = true;
|
||||||
|
}, [cartCards, queue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cartCards.length === 0) {
|
||||||
|
onEmpty?.();
|
||||||
|
}
|
||||||
|
}, [cartCards.length, onEmpty]);
|
||||||
|
|
||||||
|
const handleCommitOwned = async () => {
|
||||||
|
setCommitError(null);
|
||||||
|
const beforeIds = cartCards
|
||||||
|
.filter((card) => queue.selectedCards.has(card.id))
|
||||||
|
.map((card) => card.id);
|
||||||
|
|
||||||
|
if (beforeIds.length === 0) return;
|
||||||
|
|
||||||
|
const successfulIds = await queue.commitSelectedToOwned();
|
||||||
|
if (!successfulIds?.length) {
|
||||||
|
setCommitError('Could not add cards. Try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-4 sm:px-6 pb-2">
|
||||||
|
<h2
|
||||||
|
id={headerId}
|
||||||
|
className="text-lg font-semibold"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{cartCards.length} {cartCards.length === 1 ? 'card' : 'cards'} scanned
|
||||||
|
</h2>
|
||||||
|
{lowConfidenceCount > 0 && (
|
||||||
|
<p className="text-sm mt-1" style={{ color: 'var(--accent-ember)' }}>
|
||||||
|
{lowConfidenceCount} {lowConfidenceCount === 1 ? 'needs' : 'need'} review
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto px-4 sm:px-6 py-3"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{cartCards.map((card) => (
|
||||||
|
<ReviewCardItem
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
selected={queue.selectedCards.has(card.id)}
|
||||||
|
onToggleSelect={() => queue.toggleCardSelection(card.id)}
|
||||||
|
showCheckbox
|
||||||
|
confidence={card.confidence}
|
||||||
|
onIncrement={() => queue.incrementCardQuantity(card.id)}
|
||||||
|
onDecrement={() => queue.decrementCardQuantity(card.id)}
|
||||||
|
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
|
||||||
|
onRemove={() => queue.removeScannedCard(card.id)}
|
||||||
|
isAdding={queue.addingCardIds?.has(card.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CheckoutFooter
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
isProcessing={queue.isProcessing}
|
||||||
|
commitError={commitError}
|
||||||
|
onCommitOwned={handleCommitOwned}
|
||||||
|
onOpenListPicker={onOpenListPicker}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerCheckoutSheet({
|
||||||
|
queue,
|
||||||
|
onClose,
|
||||||
|
onOpenListPicker,
|
||||||
|
trapActive = true,
|
||||||
|
}) {
|
||||||
|
const sheetRef = useFocusTrap(trapActive);
|
||||||
|
const backdropRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const backdrop = backdropRef.current;
|
||||||
|
const sheet = sheetRef.current;
|
||||||
|
if (!backdrop || !sheet) return undefined;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.dataset.visible = 'true';
|
||||||
|
sheet.dataset.visible = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trapActive) return undefined;
|
||||||
|
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose, sheetRef, trapActive]);
|
||||||
|
|
||||||
|
const handleBackdropClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="fixed inset-0 z-40 checkout-backdrop"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="checkout-sheet-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className="fixed bottom-0 left-0 right-0 glass-panel-strong rounded-t-2xl overflow-hidden checkout-sheet flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||||||
|
<div
|
||||||
|
className="w-10 h-1 rounded-full"
|
||||||
|
style={{ backgroundColor: 'var(--text-secondary)', opacity: 0.4 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-3 top-2 flex items-center justify-center rounded-lg"
|
||||||
|
style={{
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
aria-label="Close cart"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={onOpenListPicker}
|
||||||
|
onEmpty={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.checkout-backdrop {
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
transition: background 300ms ease-out, backdrop-filter 300ms ease-out,
|
||||||
|
-webkit-backdrop-filter 300ms ease-out;
|
||||||
|
}
|
||||||
|
.checkout-backdrop[data-visible='true'] {
|
||||||
|
background: var(--modal-scrim);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-high));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-high));
|
||||||
|
}
|
||||||
|
.checkout-sheet {
|
||||||
|
max-height: 85vh;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 300ms ease-out;
|
||||||
|
}
|
||||||
|
.checkout-sheet[data-visible='true'] {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,25 @@
|
||||||
export default function ScannerCountPill({ count, onReview }) {
|
export default function ScannerCountPill({ count, onReview, onOpenCheckout }) {
|
||||||
|
const handleClick = onOpenCheckout || onReview;
|
||||||
|
|
||||||
if (count <= 0) return null;
|
if (count <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel rounded-xl px-4 py-3 flex items-center justify-between">
|
<button
|
||||||
<span
|
type="button"
|
||||||
className="text-sm font-medium"
|
onClick={handleClick}
|
||||||
style={{ color: 'var(--text-primary)' }}
|
className="rounded-full px-4 py-2 text-sm font-semibold transition-transform active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
style={{
|
||||||
{count} {count === 1 ? 'card' : 'cards'} scanned
|
background: 'var(--gradient-primary)',
|
||||||
</span>
|
color: 'var(--text-primary-dark)',
|
||||||
|
boxShadow: 'var(--ember-rim-pronounced)',
|
||||||
<button
|
minHeight: 44,
|
||||||
type="button"
|
minWidth: 44,
|
||||||
onClick={onReview}
|
'--tw-ring-color': 'var(--accent-ember)',
|
||||||
className="text-sm font-medium underline-offset-2 hover:underline transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded px-2 py-1"
|
'--tw-ring-offset-color': 'transparent',
|
||||||
style={{
|
}}
|
||||||
color: 'var(--accent-ember)',
|
aria-label={`Review ${count} scanned cards`}
|
||||||
'--tw-ring-color': 'var(--accent-ember)',
|
>
|
||||||
'--tw-ring-offset-color': 'transparent',
|
Review {count}
|
||||||
minHeight: 44,
|
</button>
|
||||||
minWidth: 44,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
aria-label={`Review ${count} scanned cards`}
|
|
||||||
>
|
|
||||||
Review →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,48 @@
|
||||||
import { useMemo } from 'react';
|
import { ScannerCheckoutContent } from './ScannerCheckoutSheet.js';
|
||||||
import { Button } from '../ui';
|
|
||||||
import ReviewCardItem from './ReviewCardItem';
|
|
||||||
|
|
||||||
export default function ScannerReview({
|
export default function ScannerReview({
|
||||||
queue,
|
queue,
|
||||||
collections,
|
collections,
|
||||||
decks,
|
variant,
|
||||||
deckMode,
|
onOpenListPicker,
|
||||||
onContinueScanning,
|
|
||||||
onNewSession,
|
|
||||||
sessionDestination,
|
|
||||||
}) {
|
}) {
|
||||||
const { scannedCards } = queue;
|
if (variant === 'side-panel') {
|
||||||
|
const cartCount = queue.scannedCards.filter((card) => !card.processed).length;
|
||||||
|
|
||||||
const unprocessedCards = useMemo(
|
|
||||||
() => scannedCards.filter((c) => !c.processed),
|
|
||||||
[scannedCards]
|
|
||||||
);
|
|
||||||
|
|
||||||
const processedCards = useMemo(
|
|
||||||
() => scannedCards.filter((c) => c.processed),
|
|
||||||
[scannedCards]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deckProgress = deckMode
|
|
||||||
? { current: scannedCards.length, target: deckMode.targetSize }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleConfirmAll = () => {
|
|
||||||
if (unprocessedCards.length === 0) return;
|
|
||||||
const unprocessedIds = unprocessedCards.map((c) => c.id);
|
|
||||||
unprocessedIds.forEach((id) => queue.toggleCardSelection(id));
|
|
||||||
setTimeout(() => queue.handleBulkAction('owned'), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (scannedCards.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
<div className="flex flex-col h-full min-h-0 w-full" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||||
<div
|
<div
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
className="px-4 py-3"
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
<svg className="w-8 h-8" style={{ color: 'var(--text-secondary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
Cart
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
{cartCount > 0 && (
|
||||||
</svg>
|
<span className="text-sm font-normal ml-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
({cartCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
No cards to review
|
{cartCount === 0 ? (
|
||||||
</h2>
|
<div className="flex-1 flex items-center justify-center px-4 py-8 text-center">
|
||||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Scan some cards first, then come back to review and confirm them.
|
Scanned cards appear here. Use Review to commit when ready.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="primary" size="lg" onClick={onContinueScanning}>
|
</div>
|
||||||
Start Scanning
|
) : (
|
||||||
</Button>
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={onOpenListPicker}
|
||||||
|
headerId="checkout-side-panel-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Review{' '}
|
|
||||||
<span className="text-sm font-normal" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
({scannedCards.length} {scannedCards.length === 1 ? 'card' : 'cards'})
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<Button variant="ghost" size="sm" onClick={onContinueScanning}>
|
|
||||||
Continue
|
|
||||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deck progress (if applicable) */}
|
|
||||||
{deckProgress && (
|
|
||||||
<div className="px-4 sm:px-6 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div className="flex items-center justify-between text-sm mb-1.5">
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Deck progress
|
|
||||||
</span>
|
|
||||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{deckProgress.current} / {deckProgress.target}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="w-full h-2 rounded-full overflow-hidden"
|
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={deckProgress.current}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={deckProgress.target}
|
|
||||||
aria-label="Deck building progress"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, (deckProgress.current / deckProgress.target) * 100)}%`,
|
|
||||||
background: 'linear-gradient(90deg, var(--accent-ember), var(--accent-flame))',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable card list */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4" style={{ paddingBottom: '10rem' }}>
|
|
||||||
{/* Unprocessed cards first */}
|
|
||||||
{unprocessedCards.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Pending ({unprocessedCards.length})
|
|
||||||
</h3>
|
|
||||||
{unprocessedCards.map((card) => (
|
|
||||||
<ReviewCardItem
|
|
||||||
key={card.id}
|
|
||||||
card={card}
|
|
||||||
collections={collections}
|
|
||||||
decks={decks}
|
|
||||||
onIncrement={() => queue.incrementCardQuantity(card.id)}
|
|
||||||
onDecrement={() => queue.decrementCardQuantity(card.id)}
|
|
||||||
onUpdateMetadata={(patch) => queue.updateCardMetadata(card.id, patch)}
|
|
||||||
onRemove={() => queue.removeScannedCard(card.id)}
|
|
||||||
isAdding={false}
|
|
||||||
sessionDestination={sessionDestination}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processed cards */}
|
|
||||||
{processedCards.length > 0 && (
|
|
||||||
<div className={unprocessedCards.length > 0 ? 'mt-4' : ''}>
|
|
||||||
<h3 className="text-xs font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Confirmed ({processedCards.length})
|
|
||||||
</h3>
|
|
||||||
{processedCards.map((card) => (
|
|
||||||
<ReviewCardItem
|
|
||||||
key={card.id}
|
|
||||||
card={card}
|
|
||||||
collections={collections}
|
|
||||||
decks={decks}
|
|
||||||
onIncrement={() => {}}
|
|
||||||
onDecrement={() => {}}
|
|
||||||
onUpdateMetadata={() => {}}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onUndo={() => queue.updateCardMetadata(card.id, { processed: false, processedAction: null })}
|
|
||||||
isAdding={false}
|
|
||||||
sessionDestination={sessionDestination}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sticky bottom action bar */}
|
|
||||||
<div
|
|
||||||
className="sticky bottom-0 left-0 right-0 px-4 sm:px-6 py-4 glass-panel-strong"
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleConfirmAll}
|
|
||||||
disabled={unprocessedCards.length === 0}
|
|
||||||
loading={queue.isProcessing}
|
|
||||||
>
|
|
||||||
{unprocessedCards.length === 0
|
|
||||||
? 'All cards confirmed'
|
|
||||||
: `Confirm All (${unprocessedCards.length})`}
|
|
||||||
</Button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNewSession}
|
|
||||||
className="w-full mt-2 py-2 text-sm font-medium text-center transition-opacity duration-150 hover:opacity-70"
|
|
||||||
style={{ color: 'var(--text-secondary)', minHeight: '44px' }}
|
|
||||||
>
|
|
||||||
New Session
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
components/scanner/ScannerScanPeek.js
Normal file
97
components/scanner/ScannerScanPeek.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import GlassSurface from '../ui/GlassSurface.js';
|
||||||
|
|
||||||
|
const PEEK_DURATION_MS = 3000;
|
||||||
|
|
||||||
|
function formatConfidence(confidence) {
|
||||||
|
if (confidence == null || Number.isNaN(confidence)) return null;
|
||||||
|
const pct = confidence <= 1 ? Math.round(confidence * 100) : Math.round(confidence);
|
||||||
|
return `${pct}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScannerScanPeek({ card, onOpenCheckout = () => {} }) {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
}, PEEK_DURATION_MS);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!card || !visible) return null;
|
||||||
|
|
||||||
|
const cardName = card.name || card.card?.name || 'Card';
|
||||||
|
const thumbnail =
|
||||||
|
card.image_url ||
|
||||||
|
card.scanImageUrl ||
|
||||||
|
card.card?.image_url ||
|
||||||
|
null;
|
||||||
|
const confidenceLabel = formatConfidence(card.confidence);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-4 right-4 z-20 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
bottom: 'calc(5.5rem + env(safe-area-inset-bottom, 0px))',
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: `translateY(${visible ? '0' : '12px'})`,
|
||||||
|
transition: 'opacity 150ms ease, transform 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCheckout}
|
||||||
|
className="w-full pointer-events-auto text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded-2xl"
|
||||||
|
style={{
|
||||||
|
'--tw-ring-color': 'var(--accent-ember)',
|
||||||
|
'--tw-ring-offset-color': 'transparent',
|
||||||
|
}}
|
||||||
|
aria-label={`Open cart to review ${cardName}`}
|
||||||
|
>
|
||||||
|
<GlassSurface
|
||||||
|
tint="mid"
|
||||||
|
rim="subtle"
|
||||||
|
blur="mid"
|
||||||
|
className="flex items-center gap-3 rounded-2xl px-3 py-2.5"
|
||||||
|
>
|
||||||
|
{thumbnail ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- ephemeral scan thumbnail blob/url
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-[4.25rem] object-cover rounded-md flex-shrink-0"
|
||||||
|
style={{ border: '1px solid var(--border)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-12 h-[4.25rem] rounded-md flex-shrink-0 flex items-center justify-center text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold truncate"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{cardName}
|
||||||
|
</p>
|
||||||
|
{confidenceLabel && (
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{confidenceLabel} match
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,65 @@
|
||||||
const ICON_MAP = {
|
function ToastIcon({ type }) {
|
||||||
success: { glyph: '✓', color: 'var(--color-success, #10B981)' },
|
if (type === 'error') {
|
||||||
error: { glyph: '✗', color: 'var(--color-error, #EF4444)' },
|
return (
|
||||||
info: { glyph: 'ℹ', color: 'var(--color-info, #3B82F6)' },
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'info') {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_COLORS = {
|
||||||
|
success: 'var(--color-success)',
|
||||||
|
error: 'var(--color-error)',
|
||||||
|
info: 'var(--color-info)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,7 +70,7 @@ const ICON_MAP = {
|
||||||
* auto-dismiss timer.
|
* auto-dismiss timer.
|
||||||
*/
|
*/
|
||||||
export default function ScannerToast({ message, visible, type = 'success' }) {
|
export default function ScannerToast({ message, visible, type = 'success' }) {
|
||||||
const { glyph, color } = ICON_MAP[type] || ICON_MAP.success;
|
const color = ICON_COLORS[type] || ICON_COLORS.success;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -40,11 +98,11 @@ export default function ScannerToast({ message, visible, type = 'success' }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-bold flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{glyph}
|
<ToastIcon type={type} />
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-medium truncate"
|
className="text-sm font-medium truncate"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { VOCAB } from './collection-vocabulary.js';
|
import { VOCAB } from './collection-vocabulary.js';
|
||||||
|
|
||||||
export const SCANNER_SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
export const SCANNER_SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
|
||||||
|
export const SCANNER_CART_STORAGE_KEY = 'deckhearth:scanner-cart';
|
||||||
|
|
||||||
export const DEFAULT_SCANNER_DESTINATION = {
|
export const DEFAULT_SCANNER_DESTINATION = {
|
||||||
type: 'owned',
|
type: 'owned',
|
||||||
|
|
@ -43,6 +44,49 @@ export function saveScannerSession({ destination, gameFilter, scanDefaults }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadScannerCart() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(SCANNER_CART_STORAGE_KEY);
|
||||||
|
if (!saved) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (!parsed || !Array.isArray(parsed.scannedCards)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannedCards: parsed.scannedCards,
|
||||||
|
selectedCardIds: Array.isArray(parsed.selectedCardIds) ? parsed.selectedCardIds : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersistedCartCard(card) {
|
||||||
|
if (!card || typeof card !== 'object') return card;
|
||||||
|
const { capturedImage, ocrText, ...safeCard } = card;
|
||||||
|
return safeCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveScannerCart({ scannedCards, selectedCardIds }) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.setItem(
|
||||||
|
SCANNER_CART_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
scannedCards: (scannedCards || []).map(toPersistedCartCard),
|
||||||
|
selectedCardIds,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearScannerCartStorage() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.removeItem(SCANNER_CART_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function createScanCardId() {
|
export function createScanCardId() {
|
||||||
return Date.now() + Math.random();
|
return Date.now() + Math.random();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
detectCardShapesFromFrame,
|
detectCardShapesFromFrame,
|
||||||
DETECTION_START_DELAY_MS,
|
DETECTION_START_DELAY_MS,
|
||||||
|
|
@ -12,11 +12,17 @@ import {
|
||||||
* Camera stream + OpenCV shape detection loop for the card scanner.
|
* Camera stream + OpenCV shape detection loop for the card scanner.
|
||||||
* Identification callbacks stay in the parent component.
|
* Identification callbacks stay in the parent component.
|
||||||
*/
|
*/
|
||||||
export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef }) {
|
export function useCameraScanner({
|
||||||
|
onError,
|
||||||
|
onVerifyCard,
|
||||||
|
verificationPausedRef,
|
||||||
|
facingMode = 'environment',
|
||||||
|
}) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [isDetecting, setIsDetecting] = useState(false);
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
const [trackedCards, setTrackedCards] = useState([]);
|
const [trackedCards, setTrackedCards] = useState([]);
|
||||||
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
||||||
|
const [activeFacingMode, setActiveFacingMode] = useState(facingMode);
|
||||||
|
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
@ -27,11 +33,17 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
||||||
const trackedCardsRef = useRef([]);
|
const trackedCardsRef = useRef([]);
|
||||||
const nextCardIdRef = useRef(1);
|
const nextCardIdRef = useRef(1);
|
||||||
const isStreamingRef = useRef(false);
|
const isStreamingRef = useRef(false);
|
||||||
|
const activeFacingModeRef = useRef(activeFacingMode);
|
||||||
|
const facingModeInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isStreamingRef.current = isStreaming;
|
isStreamingRef.current = isStreaming;
|
||||||
}, [isStreaming]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeFacingModeRef.current = activeFacingMode;
|
||||||
|
}, [activeFacingMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
canvasRef.current.getContext('2d', { willReadFrequently: true });
|
canvasRef.current.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
@ -117,7 +129,7 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
||||||
console.log('🎥 Starting camera...');
|
console.log('🎥 Starting camera...');
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
facingMode: 'environment',
|
facingMode: activeFacingModeRef.current,
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
height: { ideal: 720 },
|
height: { ideal: 720 },
|
||||||
aspectRatio: { ideal: 16 / 9 },
|
aspectRatio: { ideal: 16 / 9 },
|
||||||
|
|
@ -207,6 +219,23 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const switchFacingMode = useCallback(() => {
|
||||||
|
setActiveFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!facingModeInitializedRef.current) {
|
||||||
|
facingModeInitializedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStreamingRef.current) return;
|
||||||
|
|
||||||
|
stopCamera();
|
||||||
|
startCamera();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles
|
||||||
|
}, [activeFacingMode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoRef,
|
videoRef,
|
||||||
canvasRef,
|
canvasRef,
|
||||||
|
|
@ -218,5 +247,7 @@ export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef
|
||||||
startCamera,
|
startCamera,
|
||||||
stopCamera,
|
stopCamera,
|
||||||
streamRef,
|
streamRef,
|
||||||
|
facingMode: activeFacingMode,
|
||||||
|
switchFacingMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
identifyTrackedCardCapture,
|
identifyTrackedCardCapture,
|
||||||
resolveDisambiguationRefineAction,
|
resolveDisambiguationRefineAction,
|
||||||
submitScanForReview,
|
submitScanForReview,
|
||||||
|
tryLayer1TextIdentify,
|
||||||
VISION_RATE_LIMIT_MS,
|
VISION_RATE_LIMIT_MS,
|
||||||
fetchVisionIdentify,
|
fetchVisionIdentify,
|
||||||
} from './scanner-card-identify.js';
|
} from './scanner-card-identify.js';
|
||||||
|
|
@ -14,6 +15,31 @@ import {
|
||||||
* Card identification, disambiguation, and review-submission flow for the scanner.
|
* Card identification, disambiguation, and review-submission flow for the scanner.
|
||||||
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
|
* Camera refs and verify-card wiring are supplied by the parent + useCameraScanner.
|
||||||
*/
|
*/
|
||||||
|
function readFileToImageData(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(imageData);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load image from gallery'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useScannerIdentification({
|
export function useScannerIdentification({
|
||||||
onCardScanned,
|
onCardScanned,
|
||||||
onError,
|
onError,
|
||||||
|
|
@ -292,6 +318,26 @@ export function useScannerIdentification({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const identifyFromGalleryFile = async (file) => {
|
||||||
|
if (!file || verificationPausedRef?.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageData = await readFileToImageData(file);
|
||||||
|
const authHeaders = getScanAuthHeaders();
|
||||||
|
const result = await tryLayer1TextIdentify(imageData, authHeaders);
|
||||||
|
const syntheticTracker = { id: `gallery-${Date.now()}`, status: 'verifying' };
|
||||||
|
|
||||||
|
if (result.handled && result.outcome) {
|
||||||
|
await applyIdentifyOutcome(syntheticTracker, imageData, result.outcome);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportScannerError('Could not identify card from gallery image');
|
||||||
|
} catch (error) {
|
||||||
|
reportScannerError(error.message || 'Gallery identify failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disambiguation,
|
disambiguation,
|
||||||
scanNotice,
|
scanNotice,
|
||||||
|
|
@ -299,5 +345,6 @@ export function useScannerIdentification({
|
||||||
handleDisambiguationPick,
|
handleDisambiguationPick,
|
||||||
handleNotInCatalog,
|
handleNotInCatalog,
|
||||||
cancelDisambiguation,
|
cancelDisambiguation,
|
||||||
|
identifyFromGalleryFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,32 @@ import {
|
||||||
fetchBatchOwnership,
|
fetchBatchOwnership,
|
||||||
fetchScannerCollections,
|
fetchScannerCollections,
|
||||||
fetchScannerDecks,
|
fetchScannerDecks,
|
||||||
routeScannedCardToDestination,
|
|
||||||
} from './scanner-route-api.js';
|
} from './scanner-route-api.js';
|
||||||
import { destinationActionKey, mergeScannedCardEntry } from './scanner-session.js';
|
import {
|
||||||
|
loadScannerCart,
|
||||||
|
mergeScannedCardEntry,
|
||||||
|
saveScannerCart,
|
||||||
|
} from './scanner-session.js';
|
||||||
|
|
||||||
const UNDO_TTL_MS = 30_000;
|
const UNDO_TTL_MS = 30_000;
|
||||||
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
|
const UNDO_CLEANUP_INTERVAL_MS = 5_000;
|
||||||
const OWNERSHIP_DEBOUNCE_MS = 500;
|
const OWNERSHIP_DEBOUNCE_MS = 500;
|
||||||
|
const CART_PERSIST_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
|
* Scanned-card queue, bulk selection, in-flight guards, and destination lists.
|
||||||
*/
|
*/
|
||||||
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
|
export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMode }) {
|
||||||
const [scannedCards, setScannedCards] = useState([]);
|
const [scannedCards, setScannedCards] = useState(() => {
|
||||||
|
const cart = loadScannerCart();
|
||||||
|
return cart?.scannedCards ?? [];
|
||||||
|
});
|
||||||
const [collections, setCollections] = useState([]);
|
const [collections, setCollections] = useState([]);
|
||||||
const [decks, setDecks] = useState([]);
|
const [decks, setDecks] = useState([]);
|
||||||
const [selectedCards, setSelectedCards] = useState(new Set());
|
const [selectedCards, setSelectedCards] = useState(() => {
|
||||||
|
const cart = loadScannerCart();
|
||||||
|
return new Set(cart?.selectedCardIds ?? []);
|
||||||
|
});
|
||||||
const [bulkAction, setBulkAction] = useState('');
|
const [bulkAction, setBulkAction] = useState('');
|
||||||
const [bulkTarget, setBulkTarget] = useState('');
|
const [bulkTarget, setBulkTarget] = useState('');
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
@ -34,6 +44,23 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
|
|
||||||
const addingInFlightRef = useRef(new Set());
|
const addingInFlightRef = useRef(new Set());
|
||||||
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
const [addingCardIds, setAddingCardIds] = useState(() => new Set());
|
||||||
|
const cartPersistTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const persistCart = (cards, selection) => {
|
||||||
|
saveScannerCart({
|
||||||
|
scannedCards: cards,
|
||||||
|
selectedCardIds: Array.from(selection),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(cartPersistTimerRef.current);
|
||||||
|
cartPersistTimerRef.current = setTimeout(() => {
|
||||||
|
persistCart(scannedCards, selectedCards);
|
||||||
|
}, CART_PERSIST_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(cartPersistTimerRef.current);
|
||||||
|
}, [scannedCards, selectedCards]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
@ -135,7 +162,7 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
|
setUndoStack((prev) => prev.filter((u) => u.cardId !== cardId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCardScanned = async (cardData) => {
|
const handleCardScanned = (cardData) => {
|
||||||
setAutoRouteError(null);
|
setAutoRouteError(null);
|
||||||
|
|
||||||
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
const { cardEntry, scannedCards: nextQueue } = mergeScannedCardEntry(
|
||||||
|
|
@ -144,28 +171,11 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
scanDefaults
|
scanDefaults
|
||||||
);
|
);
|
||||||
setScannedCards(nextQueue);
|
setScannedCards(nextQueue);
|
||||||
|
setSelectedCards((prev) => {
|
||||||
if (!sessionDestination) return;
|
const next = new Set([...prev, cardEntry.id]);
|
||||||
|
persistCart(nextQueue, next);
|
||||||
if (!cardEntry.databaseId) {
|
return next;
|
||||||
setAutoRouteError(
|
});
|
||||||
`"${cardEntry.name}" was queued but is not in the catalog yet — add it manually after review.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!tryBeginAdding(cardEntry.id)) return;
|
|
||||||
await routeScannedCardToDestination(cardEntry, sessionDestination);
|
|
||||||
markCardAsProcessed(cardEntry.id, destinationActionKey(sessionDestination));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auto-route failed:', error);
|
|
||||||
setAutoRouteError(
|
|
||||||
`Could not add "${cardEntry.name}" to ${sessionDestination.label}. Use the card actions below.`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
endAdding(cardEntry.id);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const incrementCardQuantity = (cardId) => {
|
const incrementCardQuantity = (cardId) => {
|
||||||
|
|
@ -231,9 +241,10 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
const handleBulkAction = async (actionOverride, targetOverride) => {
|
const handleBulkAction = async (actionOverride, targetOverride) => {
|
||||||
const action = actionOverride ?? bulkAction;
|
const action = actionOverride ?? bulkAction;
|
||||||
const target = targetOverride ?? bulkTarget;
|
const target = targetOverride ?? bulkTarget;
|
||||||
if (!action || selectedCards.size === 0) return;
|
if (!action || selectedCards.size === 0) return [];
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
const successfulIds = [];
|
||||||
try {
|
try {
|
||||||
const cardsToProcess = scannedCards.filter((card) => selectedCards.has(card.id));
|
const cardsToProcess = scannedCards.filter((card) => selectedCards.has(card.id));
|
||||||
|
|
||||||
|
|
@ -242,6 +253,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addScannedCardToOwned(card);
|
await addScannedCardToOwned(card);
|
||||||
|
successfulIds.push(card.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card to owned:', error);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +263,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addScannedCardToCollection(card, target);
|
await addScannedCardToCollection(card, target);
|
||||||
|
successfulIds.push(card.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card to collection:', error);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
|
|
@ -256,16 +273,24 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
if (!tryBeginAdding(card.id)) continue;
|
if (!tryBeginAdding(card.id)) continue;
|
||||||
try {
|
try {
|
||||||
await addScannedCardToDeck(card, target);
|
await addScannedCardToDeck(card, target);
|
||||||
|
successfulIds.push(card.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card to deck:', error);
|
||||||
} finally {
|
} finally {
|
||||||
endAdding(card.id);
|
endAdding(card.id);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
markCardAsProcessed(card.id, action);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCards(new Set());
|
if (successfulIds.length > 0) {
|
||||||
|
setScannedCards((prev) => prev.filter((card) => !successfulIds.includes(card.id)));
|
||||||
|
setSelectedCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
successfulIds.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setBulkAction('');
|
setBulkAction('');
|
||||||
setBulkTarget('');
|
setBulkTarget('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -273,8 +298,16 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return successfulIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commitSelectedToOwned = async () => handleBulkAction('owned');
|
||||||
|
const commitSelectedToCollection = async (collectionId) =>
|
||||||
|
handleBulkAction('collection', collectionId);
|
||||||
|
|
||||||
|
const unprocessedCount = scannedCards.filter((card) => !card.processed).length;
|
||||||
|
|
||||||
const createCollection = async () => {
|
const createCollection = async () => {
|
||||||
if (!newCollectionName.trim()) return;
|
if (!newCollectionName.trim()) return;
|
||||||
|
|
||||||
|
|
@ -339,6 +372,9 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo
|
||||||
addSingleCardToCollection,
|
addSingleCardToCollection,
|
||||||
addSingleCardToDeck,
|
addSingleCardToDeck,
|
||||||
handleBulkAction,
|
handleBulkAction,
|
||||||
|
commitSelectedToOwned,
|
||||||
|
commitSelectedToCollection,
|
||||||
|
unprocessedCount,
|
||||||
createCollection,
|
createCollection,
|
||||||
clearScannedCards,
|
clearScannedCards,
|
||||||
removeScannedCard,
|
removeScannedCard,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
||||||
import { Button, Input } from '../components/ui';
|
import { Button, Input } from '../components/ui';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
|
export function isSafeAppReturnUrl(returnUrl, origin = '') {
|
||||||
|
if (typeof returnUrl !== 'string') return false;
|
||||||
|
if (!returnUrl.startsWith('/') || returnUrl.startsWith('//')) return false;
|
||||||
|
if (!origin) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = new URL(returnUrl, origin);
|
||||||
|
return resolved.origin === origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|
@ -44,6 +57,14 @@ export default function Login() {
|
||||||
// Store the JWT token in localStorage
|
// Store the JWT token in localStorage
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
|
||||||
|
const returnUrl =
|
||||||
|
typeof router.query.returnUrl === 'string' ? router.query.returnUrl : null;
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
if (isSafeAppReturnUrl(returnUrl, origin)) {
|
||||||
|
router.push(returnUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to admin panel or dashboard
|
// Redirect to admin panel or dashboard
|
||||||
if (data.user.role === 'admin') {
|
if (data.user.role === 'admin') {
|
||||||
router.push('/admin/card-editor');
|
router.push('/admin/card-editor');
|
||||||
|
|
|
||||||
212
pages/scanner.js
212
pages/scanner.js
|
|
@ -1,36 +1,31 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import ScannerSetup from '../components/scanner/ScannerSetup';
|
|
||||||
import ScannerCamera from '../components/scanner/ScannerCamera';
|
import ScannerCamera from '../components/scanner/ScannerCamera';
|
||||||
import ScannerReview from '../components/scanner/ScannerReview';
|
import ScannerReview from '../components/scanner/ScannerReview';
|
||||||
|
import ScannerCheckoutSheet from '../components/scanner/ScannerCheckoutSheet';
|
||||||
|
import { Modal, Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import { useScannerSession } from '../lib/use-scanner-session.js';
|
import { useScannerSession } from '../lib/use-scanner-session.js';
|
||||||
import { useScannerQueue } from '../lib/use-scanner-queue.js';
|
import { useScannerQueue } from '../lib/use-scanner-queue.js';
|
||||||
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
||||||
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
import { useScannerIdentification } from '../lib/use-scanner-identification.js';
|
||||||
|
import { clearScannerCartStorage } from '../lib/scanner-session.js';
|
||||||
|
import { collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
export default function Scanner() {
|
export default function Scanner() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [phase, setPhase] = useState('setup');
|
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||||
const [deckMode, setDeckMode] = useState(null);
|
const [isListPickerOpen, setIsListPickerOpen] = useState(false);
|
||||||
|
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||||
|
const [listCommitError, setListCommitError] = useState(null);
|
||||||
|
const verificationPausedRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const { scanDefaults } = useScannerSession();
|
||||||
sessionDestination,
|
const queue = useScannerQueue({ user, scanDefaults, deckMode: null });
|
||||||
setSessionDestination,
|
|
||||||
gameFilter,
|
|
||||||
setGameFilter,
|
|
||||||
scanDefaults,
|
|
||||||
setScanDefaults,
|
|
||||||
scanHistory,
|
|
||||||
saveScanToHistory,
|
|
||||||
} = useScannerSession();
|
|
||||||
|
|
||||||
const queue = useScannerQueue({ user, sessionDestination, scanDefaults, deckMode });
|
|
||||||
|
|
||||||
const onVerifyCardRef = useRef(null);
|
const onVerifyCardRef = useRef(null);
|
||||||
const verificationPausedRef = useRef(false);
|
|
||||||
|
|
||||||
const camera = useCameraScanner({
|
const camera = useCameraScanner({
|
||||||
onError: (msg) => console.error('Camera error:', msg),
|
onError: (msg) => console.error('Camera error:', msg),
|
||||||
|
|
@ -47,12 +42,50 @@ export default function Scanner() {
|
||||||
verificationPausedRef,
|
verificationPausedRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unprocessedCount = queue.unprocessedCount;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
router.push('/login');
|
router.push(`/login?returnUrl=${encodeURIComponent('/scanner')}`);
|
||||||
}
|
}
|
||||||
}, [authLoading, user, router]);
|
}, [authLoading, user, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mobileCheckoutPauses =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(max-width: 767px)').matches &&
|
||||||
|
isCheckoutOpen;
|
||||||
|
|
||||||
|
verificationPausedRef.current =
|
||||||
|
mobileCheckoutPauses ||
|
||||||
|
isListPickerOpen ||
|
||||||
|
Boolean(identification.disambiguation);
|
||||||
|
}, [isCheckoutOpen, isListPickerOpen, identification.disambiguation]);
|
||||||
|
|
||||||
|
const handleLeaveConfirm = () => {
|
||||||
|
clearScannerCartStorage();
|
||||||
|
queue.clearScannedCards();
|
||||||
|
setShowLeaveModal(false);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListPick = async (collectionId) => {
|
||||||
|
setListCommitError(null);
|
||||||
|
const selectedIds = queue.scannedCards
|
||||||
|
.filter((card) => queue.selectedCards.has(card.id))
|
||||||
|
.map((card) => card.id);
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
|
||||||
|
const successfulIds = await queue.commitSelectedToCollection(collectionId);
|
||||||
|
if (!successfulIds?.length) {
|
||||||
|
setListCommitError('Could not add cards to the list. Try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsListPickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout user={null}>
|
<Layout user={null}>
|
||||||
|
|
@ -71,51 +104,112 @@ export default function Scanner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user} chrome="immersive">
|
||||||
{phase === 'setup' && (
|
<div className="relative flex flex-col md:flex-row h-full min-h-0 max-md:fixed max-md:inset-0">
|
||||||
<ScannerSetup
|
<div
|
||||||
sessionDestination={sessionDestination}
|
className="flex-1 min-h-0 min-w-0"
|
||||||
onDestinationChange={setSessionDestination}
|
inert={isCheckoutOpen ? true : undefined}
|
||||||
gameFilter={gameFilter}
|
aria-hidden={isCheckoutOpen || undefined}
|
||||||
onGameFilterChange={setGameFilter}
|
>
|
||||||
collections={queue.collections}
|
<ScannerCamera
|
||||||
decks={queue.decks}
|
queue={queue}
|
||||||
deckMode={deckMode}
|
camera={camera}
|
||||||
onDeckModeChange={setDeckMode}
|
identification={identification}
|
||||||
onStartScanning={() => setPhase('scanning')}
|
onBack={() =>
|
||||||
scanHistory={scanHistory}
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{phase === 'scanning' && (
|
<div className="md:hidden">
|
||||||
<ScannerCamera
|
{isCheckoutOpen && (
|
||||||
queue={queue}
|
<ScannerCheckoutSheet
|
||||||
camera={camera}
|
queue={queue}
|
||||||
identification={identification}
|
onClose={() => setIsCheckoutOpen(false)}
|
||||||
deckMode={deckMode}
|
onOpenListPicker={() => setIsListPickerOpen(true)}
|
||||||
onReview={() => setPhase('review')}
|
trapActive={!isListPickerOpen}
|
||||||
onStopSession={() => {
|
/>
|
||||||
camera.stopCamera();
|
)}
|
||||||
setPhase('review');
|
</div>
|
||||||
}}
|
|
||||||
sessionDestination={sessionDestination}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'review' && (
|
<div
|
||||||
<ScannerReview
|
className="hidden md:flex md:w-[360px] md:flex-shrink-0 md:border-l md:min-h-0"
|
||||||
queue={queue}
|
style={{ borderColor: 'var(--border)' }}
|
||||||
collections={queue.collections}
|
>
|
||||||
decks={queue.decks}
|
<ScannerReview
|
||||||
deckMode={deckMode}
|
queue={queue}
|
||||||
onContinueScanning={() => setPhase('scanning')}
|
collections={queue.collections}
|
||||||
onNewSession={() => {
|
variant="side-panel"
|
||||||
queue.clearScannedCards();
|
onOpenListPicker={() => setIsListPickerOpen(true)}
|
||||||
setPhase('setup');
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={showLeaveModal}
|
||||||
|
onClose={() => setShowLeaveModal(false)}
|
||||||
|
title="Leave scanner?"
|
||||||
|
description={`${unprocessedCount} scanned ${unprocessedCount === 1 ? 'card' : 'cards'} haven't been added yet. Leaving will clear your cart.`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||||
|
<Button variant="secondary" onClick={() => setShowLeaveModal(false)}>
|
||||||
|
Keep scanning
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleLeaveConfirm}>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={isListPickerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsListPickerOpen(false);
|
||||||
|
setListCommitError(null);
|
||||||
}}
|
}}
|
||||||
sessionDestination={sessionDestination}
|
title="Choose a List"
|
||||||
/>
|
description="Add the selected cards to one of your lists."
|
||||||
)}
|
>
|
||||||
|
{listCommitError && (
|
||||||
|
<div
|
||||||
|
className="mb-3 px-3 py-2 rounded-lg text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--color-error) 35%, transparent)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{listCommitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2 max-h-[50vh] overflow-y-auto">
|
||||||
|
{queue.collections.map((collection) => (
|
||||||
|
<li key={collection.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleListPick(collection.id)}
|
||||||
|
disabled={queue.isProcessing}
|
||||||
|
className="w-full text-left px-4 py-3 rounded-xl transition-opacity hover:opacity-80 disabled:opacity-50 glass-panel"
|
||||||
|
style={{ color: 'var(--text-primary)', minHeight: 44 }}
|
||||||
|
>
|
||||||
|
{collectionDisplayName(collection)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{queue.collections.length === 0 && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No lists yet. Create a list from the Lists page first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,84 @@ describe('Layout — floating popovers use .glass-panel-strong (regression: Brie
|
||||||
expect(inlineStyle).toContain('--elevation-pronounced');
|
expect(inlineStyle).toContain('--elevation-pronounced');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Layout — immersive chrome (regression: scanner-mobile-checkout Brief 1)', () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const testUser = { email: 'foo@bar.com', role: 'user' };
|
||||||
|
|
||||||
|
const mobileBottomNavSelector =
|
||||||
|
'.md\\:hidden.fixed.bottom-0.left-0.right-0.z-50';
|
||||||
|
|
||||||
|
it('hides the mobile bottom navigation when chrome is immersive', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout user={testUser} chrome="immersive">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
expect(container.querySelector(mobileBottomNavSelector)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the mobile bottom navigation when chrome is default', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout user={testUser} chrome="default">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
expect(container.querySelector(mobileBottomNavSelector)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps TopSearchBar in the DOM with a mobile-only hide wrapper when immersive', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout user={testUser} chrome="immersive">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText('Open command palette to search')
|
||||||
|
).toBeTruthy();
|
||||||
|
const topBarWrapper = container.querySelector('.max-md\\:hidden');
|
||||||
|
expect(topBarWrapper).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
topBarWrapper.querySelector('[aria-label="Open command palette to search"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wrap TopSearchBar in max-md:hidden when chrome is default', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout user={testUser} chrome="default">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.max-md\\:hidden')).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText('Open command palette to search')
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies max-md:pb-0 and max-md:p-0 shell classes when immersive', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Layout user={testUser} chrome="immersive">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
const outerShell = container.firstChild;
|
||||||
|
expect(outerShell.className).toContain('max-md:p-0');
|
||||||
|
|
||||||
|
const mainColumn = container.querySelector('.flex-1.flex.flex-col.min-w-0');
|
||||||
|
expect(mainColumn.className).toContain('max-md:pb-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders Sign-in CTA when user is null and chrome is immersive', () => {
|
||||||
|
render(
|
||||||
|
<Layout user={null} chrome="immersive">
|
||||||
|
page body
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
const links = screen.getAllByRole('link', { name: /sign in/i });
|
||||||
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const link of links) {
|
||||||
|
expect(link.getAttribute('href')).toBe('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
159
test/components/ScannerCheckoutSheet.test.js
Normal file
159
test/components/ScannerCheckoutSheet.test.js
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import ScannerCheckoutSheet, { ScannerCheckoutContent } from '../../components/scanner/ScannerCheckoutSheet';
|
||||||
|
import { VOCAB } from '../../lib/collection-vocabulary.js';
|
||||||
|
|
||||||
|
function createQueueFixture({
|
||||||
|
scannedCards = [
|
||||||
|
{
|
||||||
|
id: 'card-1',
|
||||||
|
name: 'Lightning Bolt',
|
||||||
|
set: 'Alpha',
|
||||||
|
cardNumber: '161',
|
||||||
|
quantity: 1,
|
||||||
|
condition: 'NM',
|
||||||
|
isFoil: false,
|
||||||
|
processed: false,
|
||||||
|
confidence: 0.92,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'card-2',
|
||||||
|
name: 'Counterspell',
|
||||||
|
set: 'Beta',
|
||||||
|
cardNumber: '54',
|
||||||
|
quantity: 1,
|
||||||
|
condition: 'NM',
|
||||||
|
isFoil: false,
|
||||||
|
processed: false,
|
||||||
|
confidence: 0.55,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedCardIds = [],
|
||||||
|
} = {}) {
|
||||||
|
const selectedCards = new Set(selectedCardIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannedCards,
|
||||||
|
collections: [{ id: 1, name: 'Trade Binder', is_system_collection: false }],
|
||||||
|
selectedCards,
|
||||||
|
isProcessing: false,
|
||||||
|
addingCardIds: new Set(),
|
||||||
|
toggleCardSelection: vi.fn((cardId) => {
|
||||||
|
if (selectedCards.has(cardId)) {
|
||||||
|
selectedCards.delete(cardId);
|
||||||
|
} else {
|
||||||
|
selectedCards.add(cardId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
incrementCardQuantity: vi.fn(),
|
||||||
|
decrementCardQuantity: vi.fn(),
|
||||||
|
updateCardMetadata: vi.fn(),
|
||||||
|
removeScannedCard: vi.fn(),
|
||||||
|
commitSelectedToOwned: vi.fn(async () => {}),
|
||||||
|
commitSelectedToCollection: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScannerCheckoutSheet', () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders header count for scanned cards', () => {
|
||||||
|
const queue = createQueueFixture();
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('2 cards scanned')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables primary action when nothing is selected', () => {
|
||||||
|
const queue = createQueueFixture({ selectedCardIds: [] });
|
||||||
|
queue.toggleCardSelection = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryButton = screen.getByRole('button', {
|
||||||
|
name: VOCAB.ADD_TO_MY_COLLECTION,
|
||||||
|
});
|
||||||
|
expect(primaryButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows vocab labels on checkout actions', () => {
|
||||||
|
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutSheet
|
||||||
|
queue={queue}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: VOCAB.ADD_TO_LIST })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders checkout sheet dialog without crashing', () => {
|
||||||
|
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutSheet
|
||||||
|
queue={queue}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show an error banner when commit returns successful ids', async () => {
|
||||||
|
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
|
||||||
|
queue.commitSelectedToOwned = vi.fn(async () => ['card-1']);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queue.commitSelectedToOwned).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error banner when commit returns no successful ids', async () => {
|
||||||
|
const queue = createQueueFixture({ selectedCardIds: ['card-1'] });
|
||||||
|
queue.commitSelectedToOwned = vi.fn(async () => []);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ScannerCheckoutContent
|
||||||
|
queue={queue}
|
||||||
|
onOpenListPicker={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: new RegExp(VOCAB.ADD_TO_MY_COLLECTION) })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SCANNER_DESTINATION,
|
DEFAULT_SCANNER_DESTINATION,
|
||||||
destinationActionKey,
|
destinationActionKey,
|
||||||
mergeScannedCardEntry,
|
mergeScannedCardEntry,
|
||||||
|
SCANNER_CART_STORAGE_KEY,
|
||||||
|
loadScannerCart,
|
||||||
|
saveScannerCart,
|
||||||
|
clearScannerCartStorage,
|
||||||
} from '../../lib/scanner-session.js';
|
} from '../../lib/scanner-session.js';
|
||||||
|
|
||||||
describe('destinationActionKey', () => {
|
describe('destinationActionKey', () => {
|
||||||
|
|
@ -59,3 +64,55 @@ describe('mergeScannedCardEntry', () => {
|
||||||
expect(cardEntry.scanImageUrl).toBe('https://blob/new.jpg');
|
expect(cardEntry.scanImageUrl).toBe('https://blob/new.jpg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scanner cart sessionStorage', () => {
|
||||||
|
const sampleCards = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Pikachu',
|
||||||
|
set: 'Base',
|
||||||
|
quantity: 1,
|
||||||
|
condition: 'NM',
|
||||||
|
isFoil: false,
|
||||||
|
processed: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips cart rows and selection within a tab', () => {
|
||||||
|
saveScannerCart({ scannedCards: sampleCards, selectedCardIds: [1] });
|
||||||
|
|
||||||
|
const loaded = loadScannerCart();
|
||||||
|
expect(loaded).toEqual({ scannedCards: sampleCards, selectedCardIds: [1] });
|
||||||
|
expect(sessionStorage.getItem(SCANNER_CART_STORAGE_KEY)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits capturedImage and ocrText from persisted cart rows', () => {
|
||||||
|
saveScannerCart({
|
||||||
|
scannedCards: [
|
||||||
|
{
|
||||||
|
...sampleCards[0],
|
||||||
|
capturedImage: 'data:image/png;base64,AAAA',
|
||||||
|
ocrText: 'raw ocr',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedCardIds: [1],
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = loadScannerCart();
|
||||||
|
expect(loaded.scannedCards[0].capturedImage).toBeUndefined();
|
||||||
|
expect(loaded.scannedCards[0].ocrText).toBeUndefined();
|
||||||
|
expect(loaded.scannedCards[0].name).toBe('Pikachu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearScannerCartStorage removes persisted cart state', () => {
|
||||||
|
saveScannerCart({ scannedCards: sampleCards, selectedCardIds: [1] });
|
||||||
|
clearScannerCartStorage();
|
||||||
|
|
||||||
|
expect(loadScannerCart()).toBeNull();
|
||||||
|
expect(sessionStorage.getItem(SCANNER_CART_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
225
test/lib/use-scanner-queue.test.js
Normal file
225
test/lib/use-scanner-queue.test.js
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
const routeApiMocks = vi.hoisted(() => ({
|
||||||
|
addScannedCardToOwned: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addScannedCardToCollection: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addScannedCardToDeck: vi.fn().mockResolvedValue(undefined),
|
||||||
|
routeScannedCardToDestination: vi.fn().mockResolvedValue(undefined),
|
||||||
|
fetchScannerCollections: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchScannerDecks: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchBatchOwnership: vi.fn().mockResolvedValue({}),
|
||||||
|
createScannerCollection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/scanner-route-api.js', () => ({
|
||||||
|
addScannedCardToOwned: routeApiMocks.addScannedCardToOwned,
|
||||||
|
addScannedCardToCollection: routeApiMocks.addScannedCardToCollection,
|
||||||
|
addScannedCardToDeck: routeApiMocks.addScannedCardToDeck,
|
||||||
|
routeScannedCardToDestination: routeApiMocks.routeScannedCardToDestination,
|
||||||
|
fetchScannerCollections: routeApiMocks.fetchScannerCollections,
|
||||||
|
fetchScannerDecks: routeApiMocks.fetchScannerDecks,
|
||||||
|
fetchBatchOwnership: routeApiMocks.fetchBatchOwnership,
|
||||||
|
createScannerCollection: routeApiMocks.createScannerCollection,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useScannerQueue } from '../../lib/use-scanner-queue.js';
|
||||||
|
import { clearScannerCartStorage } from '../../lib/scanner-session.js';
|
||||||
|
|
||||||
|
const {
|
||||||
|
addScannedCardToOwned,
|
||||||
|
routeScannedCardToDestination,
|
||||||
|
} = routeApiMocks;
|
||||||
|
|
||||||
|
const scanDefaults = { condition: 'NM', isFoil: false };
|
||||||
|
const user = { userId: 1, email: 'test@example.com', role: 'user' };
|
||||||
|
|
||||||
|
describe('useScannerQueue — cart model', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearScannerCartStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleCardScanned enqueues locally without route helpers', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: { type: 'owned', id: null, label: 'My Collection' },
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Pikachu',
|
||||||
|
set: 'Base',
|
||||||
|
databaseId: 101,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(routeScannedCardToDestination).not.toHaveBeenCalled();
|
||||||
|
expect(addScannedCardToOwned).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.scannedCards).toHaveLength(1);
|
||||||
|
const card = result.current.scannedCards[0];
|
||||||
|
expect(card).toMatchObject({
|
||||||
|
name: 'Pikachu',
|
||||||
|
set: 'Base',
|
||||||
|
processed: false,
|
||||||
|
});
|
||||||
|
expect(card.id).toBeTruthy();
|
||||||
|
expect(result.current.selectedCards.has(card.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleBulkAction owned commits each selected card and removes them from the cart', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: null,
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Bolt',
|
||||||
|
set: 'Alpha',
|
||||||
|
databaseId: 201,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardId = result.current.scannedCards[0].id;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleBulkAction('owned');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addScannedCardToOwned).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: cardId, name: 'Bolt', set: 'Alpha' })
|
||||||
|
);
|
||||||
|
expect(result.current.scannedCards).toHaveLength(0);
|
||||||
|
expect(result.current.selectedCards.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commitSelectedToOwned returns successful ids instead of reading stale queue state', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: null,
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Jace',
|
||||||
|
set: 'Origins',
|
||||||
|
databaseId: 301,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardId = result.current.scannedCards[0].id;
|
||||||
|
let successfulIds;
|
||||||
|
await act(async () => {
|
||||||
|
successfulIds = await result.current.commitSelectedToOwned();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(successfulIds).toEqual([cardId]);
|
||||||
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.scannedCards).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commitSelectedToOwned returns an empty array when the API fails', async () => {
|
||||||
|
addScannedCardToOwned.mockRejectedValueOnce(new Error('network'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: null,
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Jace',
|
||||||
|
set: 'Origins',
|
||||||
|
databaseId: 301,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let successfulIds;
|
||||||
|
await act(async () => {
|
||||||
|
successfulIds = await result.current.commitSelectedToOwned();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(successfulIds).toEqual([]);
|
||||||
|
expect(result.current.scannedCards).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commitSelectedToOwned routes through handleBulkAction owned', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: null,
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Jace',
|
||||||
|
set: 'Origins',
|
||||||
|
databaseId: 301,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.commitSelectedToOwned();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addScannedCardToOwned).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.scannedCards).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists cart state to sessionStorage on scan', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useScannerQueue({
|
||||||
|
user,
|
||||||
|
sessionDestination: null,
|
||||||
|
scanDefaults,
|
||||||
|
deckMode: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.handleCardScanned({
|
||||||
|
name: 'Liliana',
|
||||||
|
set: 'Midnight',
|
||||||
|
databaseId: 401,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardId = result.current.scannedCards[0].id;
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const raw = sessionStorage.getItem('deckhearth:scanner-cart');
|
||||||
|
expect(raw).toBeTruthy();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.scannedCards).toHaveLength(1);
|
||||||
|
expect(parsed.selectedCardIds).toEqual([cardId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.unprocessedCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render, screen } from '@testing-library/react';
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import Login from '../../pages/login.js';
|
import Login, { isSafeAppReturnUrl } from '../../pages/login.js';
|
||||||
|
|
||||||
vi.mock('next/router', () => ({
|
vi.mock('next/router', () => ({
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
|
@ -26,4 +26,14 @@ describe('Login page', () => {
|
||||||
expect(screen.queryByText(/quick login/i)).toBeNull();
|
expect(screen.queryByText(/quick login/i)).toBeNull();
|
||||||
expect(document.body.textContent).not.toMatch(/alice123|bob123/);
|
expect(document.body.textContent).not.toMatch(/alice123|bob123/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts same-origin relative return URLs and rejects open redirects', () => {
|
||||||
|
const origin = 'https://deckhearth.com';
|
||||||
|
expect(isSafeAppReturnUrl('/scanner', origin)).toBe(true);
|
||||||
|
expect(isSafeAppReturnUrl('/login', origin)).toBe(true);
|
||||||
|
expect(isSafeAppReturnUrl('//evil.example', origin)).toBe(false);
|
||||||
|
expect(isSafeAppReturnUrl('/\\evil.example', origin)).toBe(false);
|
||||||
|
expect(isSafeAppReturnUrl('https://evil.example', origin)).toBe(false);
|
||||||
|
expect(isSafeAppReturnUrl(null, origin)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue