# Remote Check-In (QR Web Lane) — Web Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the public, mobile-first customer surface for the QR remote-check-in lane: a Turnstile-protected multi-step form served at `checkin.alqove.com/c/{token}`, a confirmation screen with the short check-in code + signed status link, and a status page that slow-polls in `live` mode. Plus the `@alqove/api-client` endpoints and `@alqove/types` the rest of the app consumes. Consumes the API from the companion plan (`alqove-api/api/docs/superpowers/plans/2026-06-16-remote-checkin-api-plan.md`).

**Architecture:** A new **public** App-Router route group `(checkin)` that does NOT use the `localStorage` auth token in `web/src/lib/api.ts` (an optional "log in" affordance attaches the session when present). The branding shell is server-rendered at the edge for instant first paint; the interactive form is a lazily-loaded client component. No websockets — the status page polls every 30–60s; the completion SMS (API side) is the real signal. Full design context + locked decisions (D1–D18): `web/docs/superpowers/specs/2026-06-15-remote-checkin-qr-lane-design.md`.

**Tech Stack:** Next.js 16 (App Router, Turbopack, React 19), TypeScript, TanStack Query (server state), Zustand (only if needed for wizard state — prefer local component state), Tailwind v4 + `@alqove/design-tokens` + shadcn/ui, `@alqove/api-client` + `@alqove/types` (NEVER raw fetch), Cloudflare Turnstile widget (`@marsidev/react-turnstile` or the vanilla script), Vitest + Testing Library (unit/component), Playwright (e2e, mobile viewport).

**Branch:** `feature/remote-checkin-web` off `main`.

**Conventions (from `web/CLAUDE.md`):** Server components by default; `'use client'` only when interactive. Shared components in `src/components/`, page-local components colocated. Import `@alqove/api-client`; types from `@alqove/types`. Tailwind utilities + design tokens; shadcn as needed. Pages `page.tsx`, layouts `layout.tsx`, components PascalCase.

**Dependency on the API plan:** Tasks 2+ need the API contract live. The API plan updates `alqove-api/api/contracts/openapi.yaml`; this repo regenerates `@alqove/types` from **its own** `alqove-web/contracts/openapi.yaml` (`npm run build:types`). **Task 1 includes syncing that file.** Until the API is deployed locally, develop against the running Sail stack (`NEXT_PUBLIC_API_URL` defaults to `http://localhost:8000`).

**Commands:**
```bash
npm run dev:web          # from monorepo root
npm run build:types      # regenerate @alqove/types from contracts/openapi.yaml
npm run test --workspace=web        # vitest
npm run test:e2e --workspace=web    # playwright (add script if absent)
npm run typecheck --workspaces
```

**Revision history:**
- **v1 (2026-06-16)** — initial 7-task plan from the design spec.
- **v2 (2026-06-16)** — incorporate first codex plan-review (1 BLOCKER, 3 IMPORTANTs, 2 NITs), verified against `packages/api-client/src/client.ts`:
  - **B1** — `AlqoveClient.request()` auto-generates a random `Idempotency-Key` per call and exposes no per-call header/auth override → a stable idempotency key is impossible, the random header ≠ body `idempotency_key` so the API 422s every submit, and optional-login can't attach a bearer. Added **Task 0** to extend the client (per-call `headers`/`authToken`, override-not-regenerate the idempotency key) before anything else; Task 1's endpoint test now asserts header==body key + retry stability.
  - **I1** — added a `QueryClientProvider` in `(checkin)/layout.tsx`; the buyer provider doesn't wrap this public group (status poller would throw). Task 2 Step 3.
  - **I2** — clarified the optional-login token is `localStorage`-only (unreadable on edge/server); the branding shell stays anonymous and only the client `CheckinForm` mutation reads it and passes `authToken`. Tasks 2/4.
  - **I3** — elevated contract-drift from "open item" to a required pre-merge CI check diffing the two `openapi.yaml` copies.
  - **N1/N2** — noted `getBlob`/`postFormData` share the same client limitation; cross-referenced the Turnstile dev-stub with the API's dev-bypass so local/e2e submits work without a live token.

---

## File structure overview

**Create:**
```
contracts/openapi.yaml                               # (sync the new paths from alqove-api)
packages/api-client/src/endpoints/checkin.ts         # createCheckinEndpoints (public, optional auth)
web/src/app/(checkin)/layout.tsx                      # minimal public layout (no auth nav)
web/src/app/(checkin)/c/[token]/page.tsx              # branding shell (server/edge) + form mount
web/src/app/(checkin)/c/[token]/CheckinForm.tsx       # 'use client' multi-step wizard
web/src/app/(checkin)/c/[token]/StorePausedNotice.tsx # 423/disabled state
web/src/app/(checkin)/c/[token]/s/[statusToken]/page.tsx        # status shell
web/src/app/(checkin)/c/[token]/s/[statusToken]/StatusPoller.tsx # 'use client' polling
web/src/lib/checkin/idempotency.ts                    # generate+persist UUID per (token) draft
web/src/lib/checkin/api.ts                             # a public AlqoveClient (no localStorage token)
web/src/components/checkin/*                            # StepPhone, StepContainers, StepReview, Confirmation
web/src/app/(checkin)/**/__tests__/*                   # vitest component tests
web/e2e/checkin.spec.ts                                # playwright mobile e2e
```

**Modify:**
```
packages/api-client/src/client.ts                      # (Task 0) per-call headers + idempotency key + optional auth
packages/api-client/src/index.ts                       # export createCheckinEndpoints + types
web/src/lib/api.ts                                      # (optional) export a separate public client
web/next.config.ts                                      # host routing for checkin.alqove.com (+ image domains)
web/playwright.config.ts                                # mobile project/device if not present
```

---

## Task 0 — Extend `AlqoveClient` for per-call headers + stable idempotency key (review B1 — BLOCKER)

**Why first:** verified in `packages/api-client/src/client.ts` — `getHeaders()` does `headers['Idempotency-Key'] = crypto.randomUUID()` on **every** non-GET/DELETE, and `request()` accepts only `{body, params}`. As-is the check-in submit is impossible: (1) no way to send a **stable** idempotency key → no safe retry/replay; (2) the random header `Idempotency-Key` won't equal the body `idempotency_key`, so the API's equality check **422s every submit**; (3) no way to attach an optional-login bearer per call.

**Files (modify):** `packages/api-client/src/client.ts`. **Tests:** `packages/api-client/src/__tests__/client.test.ts`.

- [x] **Step 1 (test):** extend `request()` (and `post`) options to `{ body?, params?, headers?, authToken? }`. Assert: a caller-supplied `headers['Idempotency-Key']` **overrides** the auto-generated one (no random key when explicitly set); `authToken` sets `Authorization: Bearer <authToken>` for that call only, overriding the constructed `getToken`; when neither is given, behavior is unchanged (back-compat for all existing endpoints).
- [x] **Step 2:** Implement: merge caller `headers` last in `getHeaders`; only auto-generate `Idempotency-Key` when the caller didn't supply one; thread an optional per-call `authToken`. Keep the default random-key behavior for every existing endpoint (don't break `reviews`, `checkout`, etc.). `npm run typecheck` + existing api-client tests green. Commit.

## Task 1 — Contract sync + `@alqove/types` + api-client endpoints

**Files:** `contracts/openapi.yaml`, `packages/api-client/src/endpoints/checkin.ts`, `packages/api-client/src/index.ts`.

- [x] **Step 1:** Copy the new `/v1/checkin/*` + (the customer-relevant) response schemas from the API plan's `openapi.yaml` into `alqove-web/contracts/openapi.yaml`. Run `npm run build:types`; confirm `@alqove/types` now has `CheckinBranding`, `CheckinAccepted`, `CheckinStatus`. _(Upstream API contract lacks these paths; authored/vendored here from the design spec.)_
- [x] **Step 2 (test):** `packages/api-client/src/endpoints/__tests__/checkin.test.ts` — `createCheckinEndpoints(client)` exposes `getBranding(token)`, `submit(token, payload, {idempotencyKey, turnstileToken, authToken?})`, `getStatus(statusToken)`. Built on Task 0's per-call headers. Assert: `submit` puts the **same** `idempotencyKey` in BOTH the body `idempotency_key` field AND the `Idempotency-Key` header (the API enforces equality — see API plan); includes `turnstile_token`; includes `Authorization: Bearer` ONLY when `authToken` passed; a retry with the same `idempotencyKey` re-sends the identical key (no random regeneration); `getStatus` hits `/v1/checkin/status/{token}` with no auth. Mock the client transport.
- [x] **Step 3:** Implement `checkin.ts` following the existing endpoint-factory pattern (see `reviews.ts`); types imported from `@alqove/types`. Export from `index.ts`. `npm run typecheck`. Commit.

## Task 2 — Public client + route group skeleton

**Files:** `web/src/lib/checkin/api.ts`, `(checkin)/layout.tsx`, `(checkin)/c/[token]/page.tsx`.

- [x] **Step 1:** `web/src/lib/checkin/api.ts` — a dedicated `AlqoveClient` instance whose `getToken` is the **optional** logged-in token (read once, may be null) — crucially it must work when null and never throw. Build a `checkin` endpoints object from it. (Keeps the public lane independent of the buyer `localStorage` auth flow.)
- [x] **Step 2 (test):** `(checkin)/c/[token]/page.tsx` is a **server component** that fetches branding by `params.token`. Render states: happy → store name/logo + `<CheckinForm>`; **410** (dead link) → "This check-in link is no longer active"; **423** → `<StorePausedNotice>` ("not accepting check-ins right now" + hours). Component test with the api-client mocked.
- [x] **Step 3:** Minimal `(checkin)/layout.tsx` — no buyer/seller nav, brandable header, `<meta name="viewport">` for mobile, theme from design tokens. Edge runtime + short revalidate on the branding fetch (`export const revalidate = 30`). **(review I1)** Add a `QueryClientProvider` here (a client boundary inside the layout) — the buyer app's provider does NOT wrap this public group, and Task 5's status poller needs one. **(review I2)** The optional-login token lives in `localStorage` and is unreadable on the edge/server; keep the layout + branding shell fully anonymous — only the client `CheckinForm` mutation (Task 4) reads the token and passes it as `authToken`. Commit.

## Task 3 — Idempotency key + multi-step form

**Files:** `web/src/lib/checkin/idempotency.ts`, `CheckinForm.tsx`, `components/checkin/Step*`.

- [x] **Step 1 (test):** `idempotency.ts` — `getOrCreateIdempotencyKey(token)` returns a stable UUID persisted in `localStorage` keyed by token+draft, regenerated only after a successful submit/reset. Vitest with a jsdom localStorage.
- [x] **Step 2 (test):** `CheckinForm` (client) — 3 steps: **phone** (`inputmode=tel`, format/validate), **containers** (count 1–20 stepper + short description), **review + submit** (loyalty opt-in, `opt_txn` SMS consent, optional "log in to attach" link). Large touch targets; keyboard/aria correctness (WCAG-AA). Step validation blocks advancing. Testing-Library: fill + advance + assert payload shape.
- [x] **Step 3:** Implement steps as colocated components; wizard state via local `useReducer` (avoid Zustand unless shared). Commit.

## Task 4 — Turnstile + submit handling

**Files:** `CheckinForm.tsx` (+ Turnstile widget), `components/checkin/Confirmation.tsx`.

- [x] **Step 1 (test):** submit calls `checkin.submit(token, payload, {idempotencyKey, turnstileToken, authToken?})` via TanStack Query mutation. Assert handling of each response: **201/200** → `<Confirmation>` with `checkin_code` + a "show staff your phone" line + a button to the status page (`/c/{token}/s/{status_token}`), and persist the `status_url` locally; **409** → friendly "already submitted" → route to existing status if available; **410/423** → the dead/paused states; network error → retry uses the SAME idempotency key (safe replay).
- [x] **Step 2:** Mount Turnstile (invisible-first); block submit until a token is present; pass `turnstileToken`. In dev (no site key), render a no-op stub so local flows work (mirrors the API's bypass).
- [x] **Step 3:** Implement Confirmation; persist `status_token`/`status_url`. Commit.

## Task 5 — Status page (slow polling, per-store mode)

**Files:** `c/[token]/s/[statusToken]/page.tsx`, `StatusPoller.tsx`.

- [x] **Step 1 (test):** server shell fetches initial status by `statusToken` (404 → "we couldn't find that check-in"). `StatusPoller` (client) uses TanStack Query with `refetchInterval` 30–60s ONLY when the store is `live`; `confirmation_only` shows a static "you're checked in" with no position. Renders `queue_position`/`estimated_wait_minutes` when present. Component tests with fake timers asserting the poll interval + that polling stops on a terminal status.
- [x] **Step 2:** Implement. Ensure the page is reachable cold (deep link from the SMS) with only the `statusToken` — no auth, no prior client state. Commit.

## Task 6 — Optimization, subdomain routing, accessibility

**Files:** `next.config.ts`, layout, images.

- [x] **Step 1:** Host routing so `checkin.alqove.com` serves the `(checkin)` group (Next middleware or rewrites); keep cookie/storage isolation from the main app. Document the DNS/deploy step.
- [x] **Step 2:** Edge-render the branding shell; `next/dynamic` lazy-load `CheckinForm` to keep initial JS minimal; configure `next/image` for store logos/CDN; short `Cache-Control` on branding. Measure: the form route's first-load JS should be well under the buyer app's.
- [x] **Step 3:** Accessibility pass (labels, focus order, error announcements, contrast from tokens) — this is a public consumer surface (ADA). Commit.

## Task 7 — E2E (Playwright, mobile viewport)

**Files:** `web/e2e/checkin.spec.ts`, `web/playwright.config.ts`.

- [x] **Step 1:** Add a mobile project (e.g. Pixel 7 / iPhone 13) to the Playwright config if absent. _(Added a `mobile` Pixel 7 project scoped to `checkin.spec.ts`.)_
- [x] **Step 2 (e2e):** Against the running Sail API + a seeded store + an active check-in link: scan-equivalent (navigate to `/c/{token}`) → fill phone → containers → review → submit → see confirmation with a code → open the status link → see status. Plus: dead-link `/c/{badtoken}` → 410 message; paused store → 423 notice. Use the existing Playwright QA config pattern. _(Spec authored against the existing `e2e/mock-server.mjs` pattern — extended with `/v1/checkin/*` routes — since the alqove-api companion endpoints aren't deployed yet. `dead-token`→410, `paused-token`→423, `active-token`→full happy flow.)_
- [x] **Step 3:** `npm run test --workspace=web` + `typecheck` + e2e green. Commit. Open PR `feature/remote-checkin-web` → `main`. _(Unit/component vitest — 42 checkin tests — and `typecheck` are green; lint clean. The Playwright spec compiles and all 8 tests are discovered via `--list`, and the mock API + SSR error mapping were smoke-tested with curl, but the browser e2e could **not be executed in this environment**: the Playwright Chromium binary download is network-blocked and Next 16's single-dev-instance lock conflicts with the already-running dev server. Run `npx playwright install chromium && npm run test:e2e --workspace=web` where browsers are available.)_

---

## Notes / open items for the plan-review pass
- **Contract drift (review I3) — RESOLVED:** once the alqove-api agent finished the real contract, `bin/sync-openapi.sh` copied it in full; `alqove-web/contracts/openapi.yaml` is now **byte-identical** to `alqove-api/api/contracts/openapi.yaml` (`diff -q` clean) and `@alqove/types` was regenerated. The web checkin code was reconciled to the real shapes, which differed from the Task-1 vendored guess: schemas now carry their own `{ data: ... }` envelope; branding uses `name`/`logo` (not `store_name`/`store_logo_url`); `CheckinAccepted` is `{ checkin_code?, status_token?, status_url }`; and the status read has **no** `is_terminal`/`status_visibility`/`checkin_code` — terminal is derived from the `status` enum and live-mode from the presence of `queue_position`/`estimated_wait_minutes`. Still worth a CI check to keep the two copies in lock-step going forward.
- **Optional login UX:** exact affordance (inline link vs prefill from session) — keep minimal in v1; phone-only is the default path.
- **Turnstile package** choice (`@marsidev/react-turnstile` vs vanilla script) — pick during Task 4.
- **Status link entry from account:** logged-in users should also see active check-ins under their account (D8) — track as a follow-up if not in v1 scope.
