diff --git a/docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md b/docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md new file mode 100644 index 0000000..0d31f9b --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md @@ -0,0 +1,4021 @@ +# Admin Mobile PWA 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:** Make the Route Commerce admin dashboard a first-class, installable, offline-capable mobile PWA with Apple HIG Accessibility-tier legibility for outdoor/gloved use. + +**Architecture:** Mobile-first admin shell (bottom tab bar + drag-up "More" sheet + sticky top bar) inside a new `/admin/v2/*` route tree. Native `` for sheets. IndexedDB mutation queue with exponential backoff and last-write-wins conflict resolution. New design tokens (type, color, spacing, motion) enforced by a CI contrast test. Service worker with two-cache strategy (shell + data) and a real offline fallback page. Rollout via dual deployment at `/admin/v2/*` then route cutover. + +**Tech Stack:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Tailwind v4 · Vitest + Testing Library · Playwright · Lighthouse CI · IndexedDB (via `idb` library) + +**Spec:** [docs/superpowers/specs/2026-06-17-admin-mobile-pwa-design.md](../specs/2026-06-17-admin-mobile-pwa-design.md) + +**Conventions:** All tasks use the existing project's commit message style (`feat:`, `fix:`, `docs:`, `chore:`, `test:`, `refactor:`). Server actions live in `src/actions/`. Components in `src/components/`. Tests in `src/**/__tests__/` (unit) and `tests/` (E2E). RPCs are `CREATE OR REPLACE FUNCTION` and go in `db/migrations/NNN_*.sql` (next number chosen by implementer based on current production state — check `MEMORY.md` or the prod DB). + +**PR overview:** + +| PR | Title | New files | Modified files | +|---|---|---|---| +| 1 | Design system + admin shell + offline queue | 13 | 4 | +| 2 | PWA manifest + SW + icons + offline page | 21 | 6 | +| 3 | Orders v2 (list + detail) | 4 | 0 | +| 4 | Stops v2 | 2 | 0 | +| 5 | Products v2 | 2 | 0 | +| 6 | Cutover Orders + Stops + Products | 0 | 3 | +| 7 | Dashboard v2 + dashboard RPC | 1 (RPC migration) + 1 (page) | 0 | +| 8 | Dashboard cutover | 0 | 2 | + +--- + +## PR 1 — Design system + admin shell + offline queue + +This PR lays the foundation. After this PR, we have the new design tokens, the new mobile-first admin shell (visible at `/admin/v2` once we add a page), the shared components every other PR uses, the IndexedDB mutation queue, and the CI contrast test that prevents future color regressions. No existing routes are modified. + +### Task 1.1: Add design tokens to `globals.css` + +**Files:** +- Modify: `src/app/globals.css:1-100` + +- [ ] **Step 1: Add the new tokens** + +Open `src/app/globals.css`. The file currently has `@import "tailwindcss";` at the top and an `@theme {}` block with `--font-*`, `--color-surface-*`, `--color-forest-*`, `--color-sage-*`, `--color-gold-*`, `--color-warmred-*`, `--color-citrus-*` tokens. + +Add a new `@theme` block immediately after the existing one (Tailwind v4 supports multiple `@theme` blocks). This is the canonical "Field Almanac" color set and the HIG Accessibility-tier type scale: + +```css +/* ─── Field Almanac palette (mobile-first / HIG Accessibility) ─── */ +:root { + /* Surfaces */ + --color-bg: #ffffff; + --color-surface: #faf8f5; + --color-surface-2: #f5f5f7; + --color-surface-3: #e8e8ed; + + /* Text — all AAA on --color-surface (14.6 / 9.4) except --text-faint (5.3 / AA only) */ + --color-text: #1d1d1f; + --color-text-muted: #424245; + --color-text-faint: #6e6e73; + + /* Accent / status — AAA on --color-surface */ + --color-accent: #166534; /* primary actions, brand */ + --color-accent-2: #15803d; /* hover state */ + --color-accent-soft: #dcfce7; /* tinted backgrounds, accent at ~10% */ + --color-danger: #b91c1c; /* errors, destructive */ + --color-danger-soft: #fee2e2; + --color-warning: #854d0e; /* warnings, low stock */ + --color-warning-soft: #fef9c3; + --color-success: #166534; /* completed, positive */ + --color-success-soft: #dcfce7; + --color-info: #1e40af; /* informational */ + --color-info-soft: #dbeafe; +} + +/* ─── Type scale (HIG Accessibility-tier) ─── */ +:root { + --text-display: 32px; + --text-display--line-height: 1.15; + --text-display--letter-spacing: -0.01em; + + --text-h1: 24px; + --text-h1--line-height: 1.2; + + --text-h2: 19px; + --text-h2--line-height: 1.3; + + --text-body: 18px; + --text-body--line-height: 1.45; + + --text-label: 15px; + --text-label--line-height: 1.3; + --text-label--letter-spacing: 0.02em; + + --text-mono: 14px; + --text-mono--line-height: 1.4; + + --text-meta: 13px; + --text-meta--line-height: 1.4; +} + +/* ─── Spacing (8pt grid) ─── */ +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; +} + +/* ─── Motion ─── */ +:root { + --ease-apple: cubic-bezier(0.32, 0.72, 0, 1); + --duration-fast: 150ms; + --duration-base: 200ms; + --duration-slow: 320ms; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +``` + +- [ ] **Step 2: Verify Tailwind picks up the new tokens** + +Run: `npm run lint && npx tsc --noEmit` +Expected: PASS (no type/lint errors). + +Run: `npm run dev` and open `http://localhost:3000` in a browser. DevTools → Elements → `` → check the `:root` computed styles to confirm `--color-bg`, `--text-display`, `--space-4` resolve. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/globals.css +git commit -m "feat(design): add Field Almanac tokens (type, color, spacing, motion)" +``` + +### Task 1.2: Write the design-tokens contrast test + +**Files:** +- Create: `src/lib/__tests__/design-tokens.test.ts` + +- [ ] **Step 1: Create the test file** + +```typescript +// src/lib/__tests__/design-tokens.test.ts +import { describe, it, expect } from "vitest"; + +/** + * Asserts all token combinations meet WCAG contrast requirements. + * - AAA = 7:1 (every text token vs every surface it appears on), EXCEPT --color-text-faint + * which is documented as AA-only. + * - AA = 4.5:1 (for --color-text-faint). + * - Large text (18pt+ or 14pt+ bold) gets a relaxed 3:1 floor, but we require AAA anyway + * because outdoor + sun glare is harder than AAA accounts for. + */ + +type RGB = [number, number, number]; + +function parseHex(hex: string): RGB { + const h = hex.replace("#", ""); + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; +} + +function srgbToLinear(c: number): number { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} + +function luminance([r, g, b]: RGB): number { + return ( + 0.2126 * srgbToLinear(r) + + 0.7152 * srgbToLinear(g) + + 0.0722 * srgbToLinear(b) + ); +} + +function contrastRatio(fg: string, bg: string): number { + const l1 = luminance(parseHex(fg)); + const l2 = luminance(parseHex(bg)); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +const SURFACES: Record = { + bg: "#ffffff", + surface: "#faf8f5", + "surface-2": "#f5f5f7", + "surface-3": "#e8e8ed", +}; + +const TEXT_TOKENS_AAA: Record = { + text: "#1d1d1f", + "text-muted": "#424245", + accent: "#166534", + "accent-2": "#15803d", + danger: "#b91c1c", + warning: "#854d0e", + success: "#166534", + info: "#1e40af", +}; + +const TEXT_FAINTS_AA: Record = { + "text-faint": "#6e6e73", +}; + +describe("design tokens — WCAG contrast", () => { + Object.entries(TEXT_TOKENS_AAA).forEach(([token, hex]) => { + Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { + it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => { + const ratio = contrastRatio(hex, surfaceHex); + expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7); + }); + }); + }); + + Object.entries(TEXT_FAINTS_AA).forEach(([token, hex]) => { + Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { + it(`${token} on ${surfaceName} meets AA (>= 4.5:1) [documented exception]`, () => { + const ratio = contrastRatio(hex, surfaceHex); + expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5); + }); + }); + }); + + it("status-pill backgrounds (soft variants) read on surface-2", () => { + // The "soft" status backgrounds are intended for filled pills, not body text. + // We only assert they're visually distinct from the surface they sit on. + const softs: Record = { + "accent-soft": "#dcfce7", + "danger-soft": "#fee2e2", + "warning-soft": "#fef9c3", + "success-soft": "#dcfce7", + "info-soft": "#dbeafe", + }; + Object.entries(softs).forEach(([token, hex]) => { + const ratio = contrastRatio(hex, "#faf8f5"); + expect(ratio, `${token} too close to surface`).toBeLessThan(1.5); + }); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `npx vitest run src/lib/__tests__/design-tokens.test.ts` +Expected: 35+ tests, all PASS. (4 text AAA × 4 surfaces = 16, plus 1 text-faint AA × 4 surfaces = 4, plus 5 soft variant tests = 25 total. If some combinations aren't reachable in production code, narrow the SURFACES object; the goal is to cover what ships.) + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/__tests__/design-tokens.test.ts +git commit -m "test(design): assert WCAG contrast for all Field Almanac tokens" +``` + +### Task 1.3: Add Vitest configuration (if not present) + +**Files:** +- Modify: `package.json` (verify) +- Create: `vitest.config.ts` (only if not present) + +- [ ] **Step 1: Check existing Vitest config** + +Run: `cat package.json | grep -A1 -B1 "vitest\|test\":\s*\""` +Expected: Either a `"test": "vitest"` script in `package.json` (no separate config needed for basic use), or a `vitest.config.ts` at the root. + +If neither exists: +```bash +npm install --save-dev vitest @vitest/ui happy-dom @testing-library/react @testing-library/jest-dom +``` + +- [ ] **Step 2: Add a test script to package.json** (if not present) + +Open `package.json` and ensure the `scripts` section includes: + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +- [ ] **Step 3: Run all tests to confirm baseline** + +Run: `npm test` +Expected: PASS. The design-tokens test from Task 1.2 should run. + +- [ ] **Step 4: Commit (only if you changed something)** + +```bash +git add package.json package-lock.json +git commit -m "chore(test): ensure vitest is configured" +``` + +### Task 1.4: Create the IndexedDB wrapper + +**Files:** +- Create: `src/lib/offline/db.ts` + +- [ ] **Step 1: Install `idb` (a thin Promise wrapper for IndexedDB)** + +Run: `npm install idb` +Expected: package updated. + +- [ ] **Step 2: Create the database module** + +```typescript +// src/lib/offline/db.ts +import { openDB, type DBSchema, type IDBPDatabase } from "idb"; + +export interface QueuedAction { + id: string; // clientActionId (uuid v4) + actionName: string; // e.g. "markOrderReady" + payload: unknown; // JSON-serializable args + createdAt: number; // Date.now() + attempts: number; // sync attempts + lastAttemptAt: number | null; + status: "pending" | "in-flight" | "synced" | "conflict" | "failed"; + error?: string; +} + +interface RouteCommerceOfflineDB extends DBSchema { + queue: { + key: string; + value: QueuedAction; + indexes: { "by-status": string; "by-created": number }; + }; + cache: { + key: string; // e.g. "orders:brandId:abc" + value: { key: string; data: unknown; cachedAt: number }; + }; +} + +const DB_NAME = "route-commerce-offline"; +const DB_VERSION = 1; + +let dbPromise: Promise> | null = null; + +export function getOfflineDB(): Promise> { + if (typeof window === "undefined") { + throw new Error("getOfflineDB called on the server"); + } + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains("queue")) { + const queue = db.createObjectStore("queue", { keyPath: "id" }); + queue.createIndex("by-status", "status"); + queue.createIndex("by-created", "createdAt"); + } + if (!db.objectStoreNames.contains("cache")) { + db.createObjectStore("cache", { keyPath: "key" }); + } + }, + }); + } + return dbPromise; +} +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json src/lib/offline/db.ts +git commit -m "feat(offline): add IndexedDB schema for queue + cache" +``` + +### Task 1.5: Write the queue module (TDD) + +**Files:** +- Create: `src/lib/offline/queue.ts` +- Test: `src/lib/offline/queue.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/offline/queue.test.ts +import { describe, it, expect, beforeEach } from "vitest"; +import "fake-indexeddb/auto"; +import { enqueueAction, dequeueAction, getQueuedActions, markSynced, markConflict } from "./queue"; + +beforeEach(async () => { + // Clear the queue between tests + const { getOfflineDB } = await import("./db"); + const db = await getOfflineDB(); + await db.clear("queue"); +}); + +describe("offline queue", () => { + it("enqueueAction stores an action with a generated clientActionId", async () => { + const action = await enqueueAction("markOrderReady", { orderId: "abc" }); + expect(action.id).toMatch(/^[0-9a-f-]{36}$/i); + expect(action.actionName).toBe("markOrderReady"); + expect(action.status).toBe("pending"); + expect(action.attempts).toBe(0); + }); + + it("enqueueAction is idempotent on the same clientActionId", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); + const b = await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "fixed-id" }); + expect(a.id).toBe(b.id); + expect(a.id).toBe("fixed-id"); + const all = await getQueuedActions(); + expect(all).toHaveLength(1); + }); + + it("getQueuedActions returns pending actions in createdAt order", async () => { + const a = await enqueueAction("a", {}, { id: "a" }); + // Small delay to ensure ordering + await new Promise((r) => setTimeout(r, 5)); + const b = await enqueueAction("b", {}, { id: "b" }); + const all = await getQueuedActions(); + expect(all.map((x) => x.id)).toEqual([a.id, b.id]); + }); + + it("dequeueAction removes the action and returns its data", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + const removed = await dequeueAction(a.id); + expect(removed?.id).toBe(a.id); + const all = await getQueuedActions(); + expect(all).toHaveLength(0); + }); + + it("markSynced removes the action", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + await markSynced(a.id); + const all = await getQueuedActions(); + expect(all).toHaveLength(0); + }); + + it("markConflict moves the action to conflict status", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + await markConflict(a.id, "stale updated_at"); + const all = await getQueuedActions(); + expect(all).toHaveLength(1); + expect(all[0].status).toBe("conflict"); + expect(all[0].error).toBe("stale updated_at"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/offline/queue.test.ts` +Expected: FAIL with "Cannot find module './queue'". + +- [ ] **Step 3: Install `fake-indexeddb` (dev) and `uuid`** + +Run: `npm install --save-dev fake-indexeddb` +Run: `npm install uuid && npm install --save-dev @types/uuid` + +- [ ] **Step 4: Implement the queue module** + +```typescript +// src/lib/offline/queue.ts +import { v4 as uuidv4 } from "uuid"; +import { getOfflineDB, type QueuedAction } from "./db"; + +export interface EnqueueOptions { + id?: string; // supply for idempotency +} + +export async function enqueueAction( + actionName: string, + payload: unknown, + options: EnqueueOptions = {} +): Promise { + const db = await getOfflineDB(); + const id = options.id ?? uuidv4(); + const existing = await db.get("queue", id); + if (existing) return existing; + const action: QueuedAction = { + id, + actionName, + payload, + createdAt: Date.now(), + attempts: 0, + lastAttemptAt: null, + status: "pending", + }; + await db.put("queue", action); + return action; +} + +export async function getQueuedActions(): Promise { + const db = await getOfflineDB(); + const all = await db.getAllFromIndex("queue", "by-created"); + return all.filter((a) => a.status !== "synced"); +} + +export async function dequeueAction(id: string): Promise { + const db = await getOfflineDB(); + const tx = db.transaction("queue", "readwrite"); + const action = await tx.store.get(id); + if (!action) { + await tx.done; + return null; + } + await tx.store.delete(id); + await tx.done; + return action; +} + +export async function markInFlight(id: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "in-flight"; + action.lastAttemptAt = Date.now(); + action.attempts += 1; + await db.put("queue", action); +} + +export async function markSynced(id: string): Promise { + const db = await getOfflineDB(); + await db.delete("queue", id); +} + +export async function markConflict(id: string, error: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "conflict"; + action.error = error; + await db.put("queue", action); +} + +export async function markFailed(id: string, error: string): Promise { + const db = await getOfflineDB(); + const action = await db.get("queue", id); + if (!action) return; + action.status = "failed"; + action.error = error; + action.attempts += 1; + action.lastAttemptAt = Date.now(); + await db.put("queue", action); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/lib/offline/queue.test.ts` +Expected: 6 tests, all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/offline/queue.ts src/lib/offline/queue.test.ts package.json package-lock.json +git commit -m "feat(offline): add IndexedDB mutation queue (enqueue, dequeue, mark synced/conflict)" +``` + +### Task 1.6: Write the sync module (TDD) + +**Files:** +- Create: `src/lib/offline/sync.ts` +- Test: `src/lib/offline/sync.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/offline/sync.test.ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { enqueueAction } from "./queue"; +import { syncPending, calculateBackoff, BACKOFF_SCHEDULE, MAX_ATTEMPTS } from "./sync"; + +beforeEach(async () => { + const { getOfflineDB } = await import("./db"); + const db = await getOfflineDB(); + await db.clear("queue"); +}); + +describe("offline sync — backoff", () => { + it("BACKOFF_SCHEDULE returns 1s, 4s, 16s, 60s, 300s", () => { + expect(BACKOFF_SCHEDULE).toEqual([1000, 4000, 16000, 60000, 300000]); + }); + + it("MAX_ATTEMPTS is 5", () => { + expect(MAX_ATTEMPTS).toBe(5); + }); + + it("calculateBackoff(0) returns the first delay", () => { + expect(calculateBackoff(0)).toBe(1000); + }); + + it("calculateBackoff(4) returns the last delay", () => { + expect(calculateBackoff(4)).toBe(300000); + }); + + it("calculateBackoff(99) caps at MAX_ATTEMPTS (gives up)", () => { + expect(calculateBackoff(99)).toBe(300000); + }); +}); + +describe("offline sync — replay", () => { + it("syncPending posts each action and marks it synced on success", async () => { + const a = await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn().mockResolvedValue({ ok: true }); + const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); + expect(dispatcher).toHaveBeenCalledWith("markOrderReady", { orderId: "abc" }, a.id); + expect(result.synced).toBe(1); + expect(result.conflicts).toBe(0); + expect(result.failed).toBe(0); + }); + + it("syncPending marks action as conflict when dispatcher returns conflict", async () => { + await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn().mockResolvedValue({ ok: false, conflict: "stale" }); + const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() }); + expect(result.conflicts).toBe(1); + const { getQueuedActions } = await import("./queue"); + const all = await getQueuedActions(); + expect(all[0].status).toBe("conflict"); + }); + + it("syncPending skips when offline", async () => { + await enqueueAction("markOrderReady", { orderId: "abc" }); + const dispatcher = vi.fn(); + const result = await syncPending(dispatcher, { online: false, sleep: () => Promise.resolve() }); + expect(dispatcher).not.toHaveBeenCalled(); + expect(result.synced).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/offline/sync.test.ts` +Expected: FAIL with "Cannot find module './sync'". + +- [ ] **Step 3: Implement the sync module** + +```typescript +// src/lib/offline/sync.ts +import { enqueueAction, getQueuedActions, markInFlight, markSynced, markConflict, markFailed } from "./queue"; + +export const BACKOFF_SCHEDULE = [1000, 4000, 16000, 60000, 300000] as const; +export const MAX_ATTEMPTS = 5; + +export function calculateBackoff(attempt: number): number { + const idx = Math.min(attempt, BACKOFF_SCHEDULE.length - 1); + return BACKOFF_SCHEDULE[idx]; +} + +export type Dispatcher = (actionName: string, payload: unknown, clientActionId: string) => Promise< + | { ok: true } + | { ok: false; conflict: string } + | { ok: false; error: string } +>; + +export interface SyncOptions { + online: boolean; + sleep: (ms: number) => Promise; + now?: () => number; +} + +export interface SyncResult { + synced: number; + conflicts: number; + failed: number; +} + +export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise { + const result: SyncResult = { synced: 0, conflicts: 0, failed: 0 }; + if (!options.online) return result; + + const actions = await getQueuedActions(); + for (const action of actions) { + if (action.status === "conflict" || action.status === "failed") { + // Don't auto-retry conflicts/failures; require manual user intervention + continue; + } + if (action.attempts >= MAX_ATTEMPTS) { + await markFailed(action.id, "max attempts reached"); + result.failed += 1; + continue; + } + + const backoff = calculateBackoff(action.attempts); + if (action.lastAttemptAt) { + const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt; + if (elapsed < backoff) continue; // not time yet + } + + await markInFlight(action.id); + try { + const dispatchResult = await dispatcher(action.actionName, action.payload, action.id); + if (dispatchResult.ok) { + await markSynced(action.id); + result.synced += 1; + } else if ("conflict" in dispatchResult) { + await markConflict(action.id, dispatchResult.conflict); + result.conflicts += 1; + } else { + await markFailed(action.id, dispatchResult.error); + result.failed += 1; + } + } catch (err) { + await markFailed(action.id, (err as Error).message); + result.failed += 1; + } + } + return result; +} + +/** + * Wire this up to navigator.onLine + 'online' event in the client. + * Returns an unsubscribe function. + */ +export function attachSyncListener(dispatcher: Dispatcher): () => void { + if (typeof window === "undefined") return () => {}; + const handler = () => { + void syncPending(dispatcher, { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }); + }; + window.addEventListener("online", handler); + // Also fire once on attach if online + if (navigator.onLine) void handler(); + return () => window.removeEventListener("online", handler); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/offline/sync.test.ts` +Expected: 8 tests, all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/offline/sync.ts src/lib/offline/sync.test.ts +git commit -m "feat(offline): add sync dispatcher with backoff and conflict surfacing" +``` + +### Task 1.7: Create the `OfflineBanner` component + +**Files:** +- Create: `src/components/admin/OfflineBanner.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +// src/components/admin/OfflineBanner.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { getQueuedActions } from "@/lib/offline/queue"; + +export function OfflineBanner() { + const [online, setOnline] = useState(true); + const [pendingCount, setPendingCount] = useState(0); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + setOnline(navigator.onLine); + const onOnline = () => setOnline(true); + const onOffline = () => setOnline(false); + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + + const interval = setInterval(async () => { + const actions = await getQueuedActions(); + setPendingCount(actions.filter((a) => a.status === "pending" || a.status === "in-flight").length); + }, 1000); + + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + clearInterval(interval); + }; + }, []); + + if (!mounted || (online && pendingCount === 0)) return null; + + return ( +
+ {online ? ( + <>Syncing {pendingCount} pending action{pendingCount === 1 ? "" : "s"}… + ) : ( + <>You're offline. Changes will sync when you reconnect. + )} +
+ ); +} + +export default OfflineBanner; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/admin/OfflineBanner.tsx +git commit -m "feat(admin): add OfflineBanner with online status + pending sync count" +``` + +### Task 1.8: Create the `AdminShell` component (breakpoint-aware) + +**Files:** +- Create: `src/components/admin/AdminShell.tsx` + +- [ ] **Step 1: Create the shell** + +```tsx +// src/components/admin/AdminShell.tsx +import { ReactNode } from "react"; +import AdminSidebar from "@/components/admin/AdminSidebar"; +import MobileTabBar from "@/components/admin/MobileTabBar"; +import OfflineBanner from "@/components/admin/OfflineBanner"; +import { useMediaQuery } from "@/lib/use-media-query"; // see Task 1.9 + +interface AdminShellProps { + userRole: string | null; + brandIds?: string[]; + activeBrandId?: string | null; + brands: { id: string; name: string; slug: string }[]; + enabledAddons?: Record; + children: ReactNode; +} + +export function AdminShell(props: AdminShellProps) { + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (isDesktop) { + return ( + <> + +
+ {props.children} +
+ + ); + } + + return ( + <> + +
+ {props.children} +
+ + + ); +} + +export default AdminShell; +``` + +- [ ] **Step 2: Commit (the build will fail until Task 1.9 lands; that's fine for the commit)** + +```bash +git add src/components/admin/AdminShell.tsx +git commit -m "feat(admin): add AdminShell with breakpoint-aware layout" +``` + +### Task 1.9: Add the `useMediaQuery` hook + +**Files:** +- Create: `src/lib/use-media-query.ts` + +- [ ] **Step 1: Create the hook** + +```typescript +// src/lib/use-media-query.ts +"use client"; + +import { useEffect, useState } from "react"; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + const mq = window.matchMedia(query); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mq.addEventListener("change", handler); + setMatches(mq.matches); + return () => mq.removeEventListener("change", handler); + }, [query]); + + return matches; +} +``` + +- [ ] **Step 2: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/use-media-query.ts +git commit -m "feat(hooks): add useMediaQuery hook for responsive component logic" +``` + +### Task 1.10: Create the `MobileTabBar` component + +**Files:** +- Create: `src/components/admin/MobileTabBar.tsx` + +- [ ] **Step 1: Create the tab bar** + +```tsx +// src/components/admin/MobileTabBar.tsx +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import MoreSheet from "@/components/admin/MoreSheet"; + +const TABS = [ + { label: "Home", href: "/admin/v2", icon: HomeIcon }, + { label: "Orders", href: "/admin/v2/orders", icon: OrdersIcon }, + { label: "Stops", href: "/admin/v2/stops", icon: StopsIcon }, + { label: "Products", href: "/admin/v2/products", icon: ProductsIcon }, +]; + +function HomeIcon({ active }: { active: boolean }) { + return ( + + + + ); +} +function OrdersIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} +function StopsIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} +function ProductsIcon({ active }: { active: boolean }) { + return ( + + + + + ); +} + +export function MobileTabBar() { + const pathname = usePathname(); + const [moreOpen, setMoreOpen] = useState(false); + + return ( + <> + + setMoreOpen(false)} /> + + ); +} + +export default MobileTabBar; +``` + +- [ ] **Step 2: Commit (the build will fail until Task 1.11 lands; that's fine)** + +```bash +git add src/components/admin/MobileTabBar.tsx +git commit -m "feat(admin): add MobileTabBar with 4 primary tabs + More trigger" +``` + +### Task 1.11: Create the `MoreSheet` component (native dialog) + +**Files:** +- Create: `src/components/admin/MoreSheet.tsx` + +- [ ] **Step 1: Create the sheet** + +```tsx +// src/components/admin/MoreSheet.tsx +"use client"; + +import Link from "next/link"; +import { useEffect, useRef } from "react"; + +const MORE_SECTIONS = [ + { + heading: "Operations", + links: [ + { label: "Pickup", href: "/admin/pickup", icon: "📦" }, + { label: "Shipping", href: "/admin/shipping", icon: "🚚" }, + { label: "Reports", href: "/admin/reports", icon: "📊" }, + { label: "Analytics", href: "/admin/analytics", icon: "📈" }, + ], + }, + { + heading: "Marketing", + links: [ + { label: "Communications", href: "/admin/communications", icon: "📧" }, + { label: "Sales", href: "/admin/sales", icon: "💵" }, + ], + }, + { + heading: "Settings", + links: [ + { label: "Settings", href: "/admin/settings", icon: "⚙️" }, + { label: "Advanced", href: "/admin/advanced", icon: "🛠" }, + ], + }, +]; + +interface MoreSheetProps { + open: boolean; + onClose: () => void; +} + +export function MoreSheet({ open, onClose }: MoreSheetProps) { + const dialogRef = useRef(null); + const lastFocusedRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open) { + lastFocusedRef.current = document.activeElement as HTMLElement | null; + if (!dialog.open) dialog.showModal(); + } else if (dialog.open) { + dialog.close(); + lastFocusedRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const onCancel = (e: Event) => { + e.preventDefault(); + onClose(); + }; + dialog.addEventListener("cancel", onCancel); + return () => dialog.removeEventListener("cancel", onCancel); + }, [onClose]); + + return ( + { + // Click on the backdrop (the dialog element itself) closes; clicks on content don't + if (e.target === dialogRef.current) onClose(); + }} + className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40" + style={{ + backgroundColor: "var(--color-bg)", + color: "var(--color-text)", + borderLeft: "1px solid var(--color-surface-3)", + }} + > +
+
+

More

+
+
+ {MORE_SECTIONS.map((section) => ( +
+

+ {section.heading.toUpperCase()} +

+
    + {section.links.map((link) => ( +
  • + + + {link.label} + +
  • + ))} +
+
+ ))} +
+
+
+ ); +} + +export default MoreSheet; +``` + +- [ ] **Step 2: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/admin/MoreSheet.tsx +git commit -m "feat(admin): add MoreSheet (native dialog) with grouped secondary nav" +``` + +### Task 1.12: Create the `PageHeader`, `StatusPill`, `EmptyState`, `CardList`, `StickyActionBar` components + +These are small, focused, presentational. Build them all in one PR by following the same TDD pattern (skip tests for purely presentational components — they're tested via visual regression in PR 3+). + +**Files:** +- Create: `src/components/admin/PageHeader.tsx` +- Create: `src/components/admin/StatusPill.tsx` +- Create: `src/components/admin/EmptyState.tsx` +- Create: `src/components/admin/CardList.tsx` +- Create: `src/components/admin/StickyActionBar.tsx` + +- [ ] **Step 1: `PageHeader.tsx`** + +```tsx +// src/components/admin/PageHeader.tsx +import { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} + +export default PageHeader; +``` + +- [ ] **Step 2: `StatusPill.tsx`** + +```tsx +// src/components/admin/StatusPill.tsx +export type StatusKind = "placed" | "ready" | "picked-up" | "cancelled" | "scheduled" | "in-transit" | "completed" | "low" | "out" | "in-stock" | "hidden"; + +const STATUS_MAP: Record = { + placed: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Placed" }, + ready: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Ready" }, + "picked-up": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Picked up" }, + cancelled: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Cancelled" }, + scheduled: { bg: "var(--color-info-soft)", fg: "var(--color-info)", label: "Scheduled" }, + "in-transit": { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "In transit" }, + completed: { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "Completed" }, + low: { bg: "var(--color-warning-soft)", fg: "var(--color-warning)", label: "Low stock" }, + out: { bg: "var(--color-danger-soft)", fg: "var(--color-danger)", label: "Out" }, + "in-stock": { bg: "var(--color-success-soft)", fg: "var(--color-success)", label: "In stock" }, + hidden: { bg: "var(--color-surface-2)", fg: "var(--color-text-muted)", label: "Hidden" }, +}; + +interface StatusPillProps { + status: StatusKind; + label?: string; +} + +export function StatusPill({ status, label }: StatusPillProps) { + const cfg = STATUS_MAP[status]; + return ( + + {label ?? cfg.label} + + ); +} + +export default StatusPill; +``` + +- [ ] **Step 3: `EmptyState.tsx`** + +```tsx +// src/components/admin/EmptyState.tsx +import { ReactNode } from "react"; + +interface EmptyStateProps { + title: string; + description?: string; + action?: ReactNode; + icon?: string; +} + +export function EmptyState({ title, description, action, icon = "✨" }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} + +export default EmptyState; +``` + +- [ ] **Step 4: `CardList.tsx` + `CardListItem.tsx`** + +```tsx +// src/components/admin/CardList.tsx +import { ReactNode } from "react"; + +interface CardListProps { + children: ReactNode; + ariaLabel?: string; +} + +export function CardList({ children, ariaLabel }: CardListProps) { + return ( +
    + {children} +
+ ); +} + +interface CardListItemProps { + onClick?: () => void; + href?: string; + children: ReactNode; + as?: "li" | "div"; +} + +export function CardListItem({ onClick, href, children, as = "li" }: CardListItemProps) { + const content = ( +
+ {children} +
+ ); + + if (href) { + return ( +
  • + + {content} + +
  • + ); + } + if (onClick) { + return ( +
  • + +
  • + ); + } + return
  • {content}
  • ; +} + +export { CardListItem }; +``` + +- [ ] **Step 5: `StickyActionBar.tsx`** + +```tsx +// src/components/admin/StickyActionBar.tsx +import { ReactNode } from "react"; + +interface StickyActionBarProps { + children: ReactNode; + pendingSync?: boolean; +} + +export function StickyActionBar({ children, pendingSync = false }: StickyActionBarProps) { + return ( +
    + {pendingSync && ( +
    + + Pending sync +
    + )} + {children} +
    + ); +} + +export default StickyActionBar; +``` + +- [ ] **Step 6: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/admin/PageHeader.tsx src/components/admin/StatusPill.tsx src/components/admin/EmptyState.tsx src/components/admin/CardList.tsx src/components/admin/StickyActionBar.tsx +git commit -m "feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar" +``` + +### Task 1.13: Add Lighthouse CI workflow + +**Files:** +- Create: `.gitea/workflows/pwa-audit.yml` + +- [ ] **Step 1: Install Lighthouse CI** + +Run: `npm install --save-dev @lhci/cli` + +- [ ] **Step 2: Create the config** + +Create `lighthouserc.json` at the project root: + +```json +{ + "ci": { + "collect": { + "url": [ + "http://localhost:3000/admin/v2/orders", + "http://localhost:3000/admin/v2/stops", + "http://localhost:3000/admin/v2/products" + ], + "numberOfRuns": 1, + "settings": { + "preset": "mobile", + "emulatedFormFactor": "mobile" + } + }, + "assert": { + "assertions": { + "categories:performance": ["error", { "minScore": 0.9 }], + "categories:accessibility": ["error", { "minScore": 0.95 }], + "categories:pwa": "error", + "installable-manifest": "error", + "service-worker": "error", + "themed-omnibox": "warn" + } + } + } +} +``` + +- [ ] **Step 3: Create the Gitea workflow** + +```yaml +# .gitea/workflows/pwa-audit.yml +name: PWA + Mobile A11y Audit +on: [pull_request] + +jobs: + lighthouse: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run start & + - run: sleep 5 + - run: npx lhci autorun + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.GH_TOKEN }} # optional +``` + +- [ ] **Step 4: Commit** + +```bash +git add .gitea/workflows/pwa-audit.yml lighthouserc.json package.json package-lock.json +git commit -m "ci: add Lighthouse PWA + mobile a11y gate" +``` + +> **Note on component tests:** The spec's Section 5 lists dedicated component tests for `MobileTabBar`, `StickyActionBar`, `OfflineBanner`, `StatusPill`, and `MoreSheet`. This plan covers their behavior via Playwright E2E tests and visual regression baselines (Tasks 3.4–3.6, 4.2). The OfflineBanner is unit-tested indirectly via the queue + sync tests (Tasks 1.5, 1.6). The visual regression approach is more meaningful for purely presentational components and avoids duplication. If dedicated unit tests are later desired, they're additive — they don't block this plan. + +PR 1 is complete. Proceed to PR 2. + +--- + +## PR 2 — PWA manifest + service worker + icons + offline page + +This PR makes the app installable. The PWA infrastructure (icons, screenshots, manifest, SW, offline page, meta tags) is added but no existing routes are modified. + +### Task 2.1: Generate the icon set + +**Files:** +- Create: `public/icons/icon-72x72.png` ... `icon-512x512.png` (8 files) +- Create: `public/icons/icon-maskable-512x512.png` +- Create: `public/icons/badge-72x72.png` +- Create: `public/icons/orders-shortcut.png`, `products-shortcut.png`, `stops-shortcut.png` + +- [ ] **Step 1: Use a Node script to generate all icons from the SVG favicon** + +Create `scripts/generate-pwa-icons.js`: + +```javascript +// scripts/generate-pwa-icons.js +// Generates the full PWA icon set from public/favicon.svg +// Run: node scripts/generate-pwa-icons.js +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const SIZES = [72, 96, 128, 144, 152, 192, 384, 512]; +const OUT_DIR = path.join(__dirname, "..", "public", "icons"); + +const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg"); +const svg = fs.readFileSync(SVG_PATH); + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + for (const size of SIZES) { + await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`)); + console.log(`✓ icon-${size}x${size}.png`); + } + // Maskable: same icon, with 20% safe-area padding baked in + await sharp(svg).resize(410, 410).extend({ + top: 51, bottom: 51, left: 51, right: 51, + background: { r: 22, g: 77, b: 46, alpha: 1 }, + }).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png")); + console.log("✓ icon-maskable-512x512.png"); + // Badge + await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png")); + console.log("✓ badge-72x72.png"); + // Shortcuts (same icon, 96x96) + for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) { + await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`)); + console.log(`✓ ${name}.png`); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); +``` + +- [ ] **Step 2: Install `sharp` and run the script** + +Run: `npm install --save-dev sharp` +Run: `node scripts/generate-pwa-icons.js` +Expected: 13 files generated under `public/icons/`. + +- [ ] **Step 3: Verify file sizes** + +Run: `ls -la public/icons/` +Expected: 13 PNGs, each 1-50KB. + +- [ ] **Step 4: Commit** + +```bash +git add public/icons/ scripts/generate-pwa-icons.js package.json package-lock.json +git commit -m "feat(pwa): generate 13-icon set from favicon (sizes 72-512 + maskable + badge + 3 shortcuts)" +``` + +### Task 2.2: Generate the screenshots + +**Files:** +- Create: `public/screenshots/dashboard.png` (1280x720, desktop) +- Create: `public/screenshots/mobile-storefront.png` (390x844, mobile) + +- [ ] **Step 1: Create screenshots manually** + +For now, generate placeholder screenshots using `sharp` from solid backgrounds with the brand color: + +Create `scripts/generate-pwa-screenshots.js`: + +```javascript +// scripts/generate-pwa-screenshots.js +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const OUT_DIR = path.join(__dirname, "..", "public", "screenshots"); +fs.mkdirSync(OUT_DIR, { recursive: true }); + +async function main() { + // Desktop dashboard placeholder + await sharp({ + create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } } + }) + .composite([{ + input: Buffer.from(` + Route Commerce + `), + top: 0, left: 0, + }]) + .png() + .toFile(path.join(OUT_DIR, "dashboard.png")); + console.log("✓ dashboard.png"); + + // Mobile storefront placeholder + await sharp({ + create: { width: 390, height: 844, channels: 3, background: { r: 250, g: 248, b: 245 } } + }) + .composite([{ + input: Buffer.from(` + Route Commerce + `), + top: 0, left: 0, + }]) + .png() + .toFile(path.join(OUT_DIR, "mobile-storefront.png")); + console.log("✓ mobile-storefront.png"); +} + +main().catch(console.error); +``` + +- [ ] **Step 2: Run the script** + +Run: `node scripts/generate-pwa-screenshots.js` +Expected: 2 PNGs created. + +- [ ] **Step 3: Replace placeholders with real screenshots before launch** + +These are placeholders. Before going to production, capture real screenshots: +1. Run `npm run dev` and visit `/admin` (desktop) and `/tuxedo` (mobile) in a real browser +2. Use `npx playwright codegen` or `npx playwright screenshot` to capture +3. Overwrite the placeholders in `public/screenshots/` +4. Re-run `git commit` with the same message + +- [ ] **Step 4: Commit** + +```bash +git add public/screenshots/ scripts/generate-pwa-screenshots.js +git commit -m "feat(pwa): add manifest screenshot placeholders (replace pre-launch with real captures)" +``` + +### Task 2.3: Generate apple-touch-icon variants and favicon.ico + +**Files:** +- Create: `public/apple-touch-icon.png` (180x180) +- Create: `public/apple-touch-icon-167.png` (167x167, iPad Pro) +- Create: `public/apple-touch-icon-152.png` (152x152, iPad) +- Create: `public/favicon.ico` + +- [ ] **Step 1: Extend the icon generation script** + +Append to `scripts/generate-pwa-icons.js`: + +```javascript + // Apple touch icons + for (const [name, size] of [["apple-touch-icon", 180], ["apple-touch-icon-167", 167], ["apple-touch-icon-152", 152]]) { + await sharp(svg).resize(size, size).png().toFile(path.join(__dirname, "..", "public", `${name}.png`)); + console.log(`✓ ${name}.png`); + } + // favicon.ico (32x32) + await sharp(svg).resize(32, 32).png().toFile(path.join(__dirname, "..", "public", "favicon.ico")); + console.log("✓ favicon.ico"); +``` + +- [ ] **Step 2: Run it** + +Run: `node scripts/generate-pwa-icons.js` +Expected: 4 new files (3 apple-touch-icons + favicon.ico). + +- [ ] **Step 3: Commit** + +```bash +git add public/apple-touch-icon*.png public/favicon.ico scripts/generate-pwa-icons.js +git commit -m "feat(pwa): add apple-touch-icon variants and favicon.ico" +``` + +### Task 2.4: Create the offline page + +**Files:** +- Create: `public/offline.html` + +- [ ] **Step 1: Create the offline page** + +```html + + + + + + + Offline — Route Commerce + + + +
    + +

    You're offline

    +

    This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.

    + +
    + + +``` + +- [ ] **Step 2: Verify** + +Open `public/offline.html` in a browser directly (or `http://localhost:3000/offline.html` after running dev). +Expected: Clean cream-colored offline card, "Try again" button. + +- [ ] **Step 3: Commit** + +```bash +git add public/offline.html +git commit -m "feat(pwa): add offline.html fallback page (HIG-styled)" +``` + +### Task 2.5: Rewrite the service worker + +**Files:** +- Modify: `public/sw.js` (full rewrite) + +- [ ] **Step 1: Replace the file** + +```javascript +// public/sw.js +// Route Commerce Service Worker +// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate) + +const SHELL_CACHE = "rc-shell-v3"; +const DATA_CACHE = "rc-data-v3"; +const OFFLINE_URL = "/offline.html"; + +const SHELL_ASSETS = [ + "/", + "/manifest.json", + "/favicon.svg", + "/og-default.svg", + "/offline.html", + "/icons/icon-192x192.png", + "/icons/icon-512x512.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + Promise.all([ + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE) + .map((name) => caches.delete(name)) + ) + ), + self.clients.claim(), + ]) + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // Navigations → network-first, fall back to cache, fall back to offline page + if (req.mode === "navigate") { + event.respondWith( + fetch(req) + .then((res) => { + const clone = res.clone(); + caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone)); + return res; + }) + .catch(() => + caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL)) + ) + ); + return; + } + + // Static assets → cache-first + if ( + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/icons/") || + url.pathname.startsWith("/screenshots/") || + url.pathname.endsWith(".svg") || + url.pathname.endsWith(".woff2") + ) { + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req).then((res) => { + const clone = res.clone(); + if (res.status === 200) { + caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone)); + } + return res; + }); + }) + ); + return; + } + + // API GETs (read-only data) → stale-while-revalidate + if (url.pathname.startsWith("/api/")) { + event.respondWith( + caches.open(DATA_CACHE).then((cache) => + cache.match(req).then((cached) => { + const network = fetch(req) + .then((res) => { + if (res.status === 200) cache.put(req, res.clone()); + return res; + }) + .catch(() => cached); + return cached || network; + }) + ) + ); + return; + } + + // Default: try network, fall back to cache + event.respondWith( + fetch(req).catch(() => caches.match(req)) + ); +}); +``` + +- [ ] **Step 2: Verify in DevTools** + +Run `npm run dev` and open DevTools → Application → Service Workers. Verify the SW registers. Reload the page and check the SHELL_CACHE contains the 7 listed assets. + +- [ ] **Step 3: Commit** + +```bash +git add public/sw.js +git commit -m "feat(pwa): rewrite service worker (shell cache + data cache + offline fallback)" +``` + +### Task 2.6: Update the manifest + +**Files:** +- Modify: `public/manifest.json` + +- [ ] **Step 1: Update theme_color, background_color, and screenshot paths** + +Edit the file to: +- Change `theme_color` to `"#166534"` +- Change `background_color` to `"#faf8f5"` +- Confirm `screenshots` array points to the files we just created + +```json +{ + "name": "Route Commerce", + "short_name": "Route Commerce", + "description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution", + "start_url": "/admin", + "scope": "/", + "display": "standalone", + "background_color": "#faf8f5", + "theme_color": "#166534", + "orientation": "portrait-primary", + "icons": [ + { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "categories": ["business", "productivity"], + "shortcuts": [ + { "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] }, + { "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] }, + { "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] } + ], + "screenshots": [ + { "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" }, + { "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" } + ], + "related_applications": [], + "prefer_related_applications": false +} +``` + +- [ ] **Step 2: Validate the manifest** + +Open `https://www.pwabuilder.com/reportcard` and paste the manifest URL once the app is deployed. Verify no errors. +For local dev: open `http://localhost:3000/manifest.json` and confirm it parses. + +- [ ] **Step 3: Commit** + +```bash +git add public/manifest.json +git commit -m "feat(pwa): update manifest (theme_color, scope, start_url, maskable icon)" +``` + +### Task 2.7: Update root layout with PWA meta + viewportFit + +**Files:** +- Modify: `src/app/layout.tsx` + +- [ ] **Step 1: Update the viewport export** + +Replace the existing viewport export (line ~30) with: + +```typescript +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + themeColor: "#166534", + viewportFit: "cover", +}; +``` + +- [ ] **Step 2: Add manifest link and apple-mobile-web-app meta to ``** + +After the `` opening tag, add the following (note: in Next.js App Router, you can use the `metadata` export for most of this, but `apple-mobile-web-app-capable` and `apple-mobile-web-app-status-bar-style` need to be in `` directly): + +In the `metadata` export, add an `appLinks` and `manifest` field. Then in the `` of the body, add the apple-specific tags via a small `` block in the JSX: + +```typescript +export const metadata: Metadata = { + // ... existing metadata + manifest: "/manifest.json", + applicationName: "Route Commerce", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Route Commerce", + }, + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, + ], + apple: [ + { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, + { url: "/apple-touch-icon-167.png", sizes: "167x167", type: "image/png" }, + { url: "/apple-touch-icon-152.png", sizes: "152x152", type: "image/png" }, + ], + }, + formatDetection: { telephone: false }, +}; +``` + +- [ ] **Step 3: Verify** + +Run: `npm run dev`, open `http://localhost:3000`, view source, confirm `` and `` are present. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/layout.tsx +git commit -m "feat(pwa): add manifest, apple-web-app meta, viewportFit=cover" +``` + +### Task 2.8: Wire `registerServiceWorker` into the client + +**Files:** +- Modify: `src/components/Providers.tsx` + +- [ ] **Step 1: Open `src/components/Providers.tsx` and add the registration** + +```typescript +"use client"; + +import { useEffect } from "react"; +import { registerServiceWorker } from "@/lib/pwa"; + +export function Providers({ children }: { children: React.ReactNode }) { + useEffect(() => { + registerServiceWorker(); + }, []); + return <>{children}; +} + +export default Providers; +``` + +(If the file already has a `Providers` component, add the `useEffect` import and the `registerServiceWorker()` call inside an existing effect or add a new one. Don't break the existing functionality.) + +- [ ] **Step 2: Verify in the browser** + +Run: `npm run dev`, open `http://localhost:3000`, DevTools → Application → Service Workers. +Expected: SW registered, status "activated and is running". + +- [ ] **Step 3: Commit** + +```bash +git add src/components/Providers.tsx +git commit -m "feat(pwa): register service worker from Providers component" +``` + +### Task 2.9: Mount the PWAInstallPrompt + +**Files:** +- Modify: `src/app/admin/layout.tsx` + +- [ ] **Step 1: Add the import and mount the component** + +At the top of `src/app/admin/layout.tsx`, add: +```typescript +import PWAInstallPrompt from "@/components/pwa/InstallPrompt"; +``` + +In the layout's return (just before the closing tag of the outermost wrapper), add: +```tsx + +``` + +- [ ] **Step 2: Verify** + +Run: `npm run dev`, open `http://localhost:3000/admin` (you may need to be signed in). The install prompt will only appear 10s after the `beforeinstallprompt` event fires (which is browser-conditional). + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/layout.tsx +git commit -m "feat(pwa): mount PWAInstallPrompt in admin layout" +``` + +### Task 2.10: Add cache headers to `next.config.ts` + +**Files:** +- Modify: `next.config.ts` + +- [ ] **Step 1: Read the existing config** + +Run: `cat next.config.ts` +If there's no `headers()` function, add one. Otherwise extend it. + +- [ ] **Step 2: Add the headers** + +```typescript +// next.config.ts (add or extend the existing module.exports) +const nextConfig = { + // ... existing config + async headers() { + return [ + { + source: "/:path*", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + ], + }, + { + source: "/_next/static/:path*", + headers: [ + { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, + ], + }, + { + source: "/icons/:path*", + headers: [ + { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, + ], + }, + { + source: "/screenshots/:path*", + headers: [ + { key: "Cache-Control", value: "public, max-age=604800" }, + ], + }, + ]; + }, +}; + +export default nextConfig; +``` + +- [ ] **Step 3: Verify** + +Run: `npm run build && npm run start`, then `curl -I http://localhost:3000/icons/icon-192x192.png` +Expected: `Cache-Control: public, max-age=31536000, immutable` in headers. + +- [ ] **Step 4: Commit** + +```bash +git add next.config.ts +git commit -m "feat(pwa): add cache headers for static assets" +``` + +### Task 2.11: Write the PWA install Playwright spec + +**Files:** +- Create: `tests/mobile-admin/pwa-install.spec.ts` + +- [ ] **Step 1: Create the directory and test file** + +```typescript +// tests/mobile-admin/pwa-install.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13 + +test("PWA manifest is valid and all icons resolve", async ({ page, request }) => { + await page.goto("/admin/login"); + const manifestLink = await page.locator('link[rel="manifest"]').getAttribute("href"); + expect(manifestLink).toBe("/manifest.json"); + + const manifestRes = await request.get("/manifest.json"); + expect(manifestRes.status()).toBe(200); + const manifest = await manifestRes.json(); + expect(manifest.name).toBe("Route Commerce"); + expect(manifest.theme_color).toBe("#166534"); + + for (const icon of manifest.icons) { + const r = await request.get(icon.src); + expect(r.status(), `icon ${icon.src} should resolve`).toBe(200); + } +}); + +test("apple-touch-icon is present in head", async ({ page }) => { + await page.goto("/admin/login"); + const appleTouch = await page.locator('link[rel="apple-touch-icon"]').count(); + expect(appleTouch).toBeGreaterThan(0); +}); + +test("service worker registers successfully", async ({ page }) => { + await page.goto("/admin/login"); + const swReady = await page.evaluate(async () => { + if (!("serviceWorker" in navigator)) return false; + const reg = await navigator.serviceWorker.ready; + return !!reg.active; + }); + expect(swReady).toBe(true); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `npx playwright test tests/mobile-admin/pwa-install.spec.ts` +Expected: 3 tests, all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/mobile-admin/pwa-install.spec.ts +git commit -m "test(pwa): add PWA install spec (manifest, icons, SW registration)" +``` + +### Task 2.12: Manual install + offline verification + +- [ ] **Step 1: Deploy to a preview URL** + +Run: `npm run build` then push to a preview deployment (Vercel or the Gitea deploy pipeline per CLAUDE.md). + +- [ ] **Step 2: On a real iPhone** + +1. Open the preview URL in Safari +2. Tap Share → Add to Home Screen +3. Confirm the app icon, name, and splash appear correctly +4. Open the installed PWA from the home screen +5. Toggle Airplane Mode on +6. Navigate to a previously visited admin page — it should render from cache +7. Navigate to a never-visited page — it should show `/offline.html` + +- [ ] **Step 3: On real Android** + +Repeat with Chrome on Android. Verify the install prompt appears (within ~10s of page load). + +- [ ] **Step 4: Document in `docs/pwa-testing.md`** + +Create the doc with your findings, screenshots, and any device-specific quirks. Commit. + +```bash +git add docs/pwa-testing.md +git commit -m "docs: PWA manual install + offline verification (real device notes)" +``` + +--- + +## PR 3 — Orders v2 (list + detail) + +This PR creates the mobile-first Orders experience at `/admin/v2/orders` and `/admin/v2/orders/[id]`. The existing v1 routes are untouched. Both layouts exist side-by-side until PR 6 (cutover). + +### Task 3.0: Create the v2 admin layout + +**Files:** +- Create: `src/app/admin/v2/layout.tsx` + +- [ ] **Step 1: Create the layout** + +```typescript +// src/app/admin/v2/layout.tsx +import AdminShell from "@/components/admin/AdminShell"; +import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { listBrandsForAdmin } from "@/actions/brands"; +import { getEnabledAddons } from "@/actions/billing/stripe-portal"; +import { ToastProvider } from "@/components/admin/Toast"; +import { ToastContainer } from "@/components/admin/ToastContainer"; +import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; +import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer"; +import { attachSyncListener } from "@/lib/offline/sync"; +import { listClientActions } from "@/actions/offline-dispatcher"; // we'll create this + +export const dynamic = "force-dynamic"; + +function ToastProviderWrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} + +export default async function AdminV2Layout({ children }: { children: React.ReactNode }) { + let adminUser = null; + try { + adminUser = await getAdminUser(); + } catch (e) { + return ; + } + if (!adminUser) { + return ; + } + + const activeBrandId = await getActiveBrandId(adminUser).catch(() => null); + const brands = await listBrandsForAdmin().catch(() => []); + const enabledAddons = activeBrandId ? await getEnabledAddons(activeBrandId).catch(() => ({})) : {}; + + return ( + + + {children} + + + + ); +} +``` + +- [ ] **Step 2: Add the offline dispatcher server module** + +Create `src/actions/offline-dispatcher.ts`: + +```typescript +// src/actions/offline-dispatcher.ts +"use server"; + +import { revalidatePath } from "next/cache"; +import { markOrderReady, markOrderPickedUp } from "@/actions/orders"; +import { updateStopStatus } from "@/actions/stops"; +import { adjustProductStock } from "@/actions/products"; + +export async function listClientActions() { + return [ + { name: "markOrderReady", handler: markOrderReady }, + { name: "markOrderPickedUp", handler: markOrderPickedUp }, + { name: "updateStopStatus", handler: updateStopStatus }, + { name: "adjustProductStock", handler: adjustProductStock }, + ]; +} + +export async function dispatchClientAction(actionName: string, payload: unknown, clientActionId: string) { + // Idempotency check (simple — production should use a real dedup table) + const actions = await listClientActions(); + const action = actions.find((a) => a.name === actionName); + if (!action) return { ok: false as const, error: "Unknown action" }; + try { + // @ts-expect-error - the handler signatures are heterogeneous; we cast at the call site + const result = await action.handler(payload, clientActionId); + if (result?.success === false) { + if (result.error?.includes("conflict") || result.error?.includes("stale")) { + return { ok: false as const, conflict: result.error }; + } + return { ok: false as const, error: result.error ?? "Action failed" }; + } + revalidatePath("/admin/v2/orders"); + revalidatePath("/admin/v2/stops"); + revalidatePath("/admin/v2/products"); + return { ok: true as const }; + } catch (err) { + return { ok: false as const, error: (err as Error).message }; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/app/admin/v2/layout.tsx src/actions/offline-dispatcher.ts +git commit -m "feat(admin): add v2 layout with AdminShell and offline dispatcher" +``` + +### Task 3.1: Create the Orders list page + +**Files:** +- Create: `src/app/admin/v2/orders/page.tsx` + +- [ ] **Step 1: Read the existing orders page to understand data shape** + +Run: `head -100 src/app/admin/orders/page.tsx` +Note the function used to fetch orders (likely `getOrdersForBrand` or similar) and the row shape. Use the same server action. + +- [ ] **Step 2: Create the v2 page** + +```typescript +// src/app/admin/v2/orders/page.tsx +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { getOrdersForBrand } from "@/actions/orders"; +import PageHeader from "@/components/admin/PageHeader"; +import { CardList, CardListItem } from "@/components/admin/CardList"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import EmptyState from "@/components/admin/EmptyState"; +import OrdersFilterButton from "@/components/admin/orders/OrdersFilterButton"; +import PullToRefresh from "@/components/admin/PullToRefresh"; +import { formatRelativeTime } from "@/lib/format-date"; + +export const dynamic = "force-dynamic"; + +export default async function OrdersV2Page({ searchParams }: { searchParams: Promise<{ status?: string; customer?: string; from?: string; to?: string }> }) { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + const brandId = (await getActiveBrandId(adminUser)) ?? undefined; + const params = await searchParams; + const orders = await getOrdersForBrand(brandId!, { + limit: 50, + status: params.status, + customer: params.customer, + from: params.from, + to: params.to, + }); + + if (orders.length === 0) { + return ( +
    + } /> + +
    + ); + } + + return ( +
    + } /> + + + {orders.map((order) => ( + +
    + + #{order.id.slice(0, 8)} + {formatRelativeTime(order.created_at)} +
    +
    {order.customer_name ?? "Customer"}
    +
    {order.item_count} item{order.item_count === 1 ? "" : "s"}
    +
    + + ${(order.total_cents / 100).toFixed(2)} + + +
    +
    + ))} +
    +
    +
    + ); +} +``` + +> **Note:** This page references `OrdersFilterButton` and `PullToRefresh` which are created in the tasks below. Create them next. + +- [ ] **Step 2.1: Verify the server action signature** + +Open `src/actions/orders.ts` and confirm `getOrdersForBrand` accepts a `brandId` plus a filter object. Adjust the call site to match the real signature. Common variations: +- `getOrdersForBrand(brandId: string, options?: { limit?: number; status?: string; customer?: string; from?: string; to?: string })` +- `getOrdersForBrand(brandId: string)` returning all orders (filter client-side as a fallback) +- The brand ID type may be `string` (not `string | undefined`); in that case, drop the `!` and the brandId resolution stays as is. + +- [ ] **Step 3: Verify** + +Run: `npm run dev`, navigate to `http://localhost:3000/admin/v2/orders` (signed in). +Expected: Card list of orders, each card showing status, customer, item count, total, time. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/admin/v2/orders/page.tsx +git commit -m "feat(admin): add v2 orders list (CardList with status pill + relative time)" +``` + +### Task 3.1.1: Add `PullToRefresh` component (custom, no library) + +**Files:** +- Create: `src/components/admin/PullToRefresh.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +// src/components/admin/PullToRefresh.tsx +"use client"; + +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +interface PullToRefreshProps { + children: ReactNode; +} + +const THRESHOLD = 80; // px of pull distance to trigger refresh +const MAX_PULL = 140; // px before rubberbanding clamps +const REFRESH_INDICATOR_HEIGHT = 56; + +export function PullToRefresh({ children }: PullToRefreshProps) { + const router = useRouter(); + const containerRef = useRef(null); + const startY = useRef(null); + const pulling = useRef(false); + const [offset, setOffset] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mq.matches); + }, []); + + function onTouchStart(e: TouchEvent) { + if (refreshing) return; + // Only start a pull if the page is scrolled to the top + if (window.scrollY > 0) return; + startY.current = e.touches[0].clientY; + pulling.current = true; + } + + function onTouchMove(e: TouchEvent) { + if (!pulling.current || startY.current === null) return; + const dy = e.touches[0].clientY - startY.current; + if (dy <= 0) { + setOffset(0); + return; + } + // Rubberband: resistance curve so it gets harder to pull further + const resistance = 1 - Math.min(1, dy / 400); + const next = Math.min(MAX_PULL, dy * resistance); + setOffset(next); + } + + async function onTouchEnd() { + if (!pulling.current) return; + pulling.current = false; + startY.current = null; + if (offset >= THRESHOLD) { + setRefreshing(true); + setOffset(REFRESH_INDICATOR_HEIGHT); + router.refresh(); + // Give the refresh a moment to complete visually + await new Promise((r) => setTimeout(r, 600)); + setRefreshing(false); + setOffset(0); + } else { + setOffset(0); + } + } + + return ( +
    + {offset > 0 && ( + + ); +} + +export default PullToRefresh; +``` + +(Add the `spin` keyframe to `globals.css` if not present.) + +- [ ] **Step 2: Verify** + +Run: `npm run dev`, open the orders list on a real phone (or with Chrome DevTools mobile emulation), pull down from the top, observe the spinner, release, observe the page refreshes. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/admin/PullToRefresh.tsx src/app/globals.css +git commit -m "feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware)" +``` + +### Task 3.1.2: Add `OrdersFilterButton` and filter sheet + +**Files:** +- Create: `src/components/admin/orders/OrdersFilterButton.tsx` + +- [ ] **Step 1: Create the button (client component that opens a native dialog)** + +```tsx +// src/components/admin/orders/OrdersFilterButton.tsx +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +export function OrdersFilterButton() { + const router = useRouter(); + const searchParams = useSearchParams(); + const dialogRef = useRef(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + + const currentStatus = searchParams.get("status") ?? ""; + + function apply(status: string) { + const params = new URLSearchParams(searchParams.toString()); + if (status) params.set("status", status); + else params.delete("status"); + router.push(`/admin/v2/orders?${params.toString()}`); + setOpen(false); + } + + return ( + <> + + { if (e.target === dialogRef.current) setOpen(false); }} + className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40" + style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }} + > +
    +
    +

    Filter

    + +
    +
    + {["", "placed", "ready", "picked-up", "cancelled"].map((s) => ( + + ))} +
    +
    +
    + + ); +} + +export default OrdersFilterButton; +``` + +- [ ] **Step 2: Verify** + +Run: `npm run dev`, navigate to `/admin/v2/orders`, tap the Filter button, observe the sheet, select a status, observe the URL updates and the list re-filters. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/admin/orders/OrdersFilterButton.tsx +git commit -m "feat(admin): add OrdersFilterButton (native dialog sheet for status filter)" +``` + +### Task 3.2: Add a `formatRelativeTime` helper + +**Files:** +- Modify: `src/lib/format-date.ts` (extend) + +- [ ] **Step 1: Read the file** + +Run: `cat src/lib/format-date.ts` +Understand the existing API. + +- [ ] **Step 2: Add the function** + +```typescript +// Add to src/lib/format-date.ts + +export function formatRelativeTime(input: string | Date | number): string { + const date = input instanceof Date ? input : new Date(input); + const diffMs = Date.now() - date.getTime(); + const diffSec = Math.round(diffMs / 1000); + if (diffSec < 45) return "just now"; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.round(diffHr / 24); + if (diffDay === 1) return "yesterday"; + if (diffDay < 7) return `${diffDay}d ago`; + return formatDate(date); +} +``` + +- [ ] **Step 3: Add unit tests** + +Create `src/lib/format-date.test.ts` (or extend if it exists): + +```typescript +// src/lib/format-date.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { formatRelativeTime } from "./format-date"; + +describe("formatRelativeTime", () => { + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-17T12:00:00Z")); }); + afterEach(() => { vi.useRealTimers(); }); + + it("returns 'just now' for < 45s", () => { + expect(formatRelativeTime("2026-06-17T11:59:30Z")).toBe("just now"); + }); + + it("returns '5m ago' for ~5 minutes", () => { + expect(formatRelativeTime("2026-06-17T11:55:00Z")).toBe("5m ago"); + }); + + it("returns '2h ago' for ~2 hours", () => { + expect(formatRelativeTime("2026-06-17T10:00:00Z")).toBe("2h ago"); + }); + + it("returns 'yesterday' for 1 day", () => { + expect(formatRelativeTime("2026-06-16T12:00:00Z")).toBe("yesterday"); + }); + + it("returns '3d ago' for 3 days", () => { + expect(formatRelativeTime("2026-06-14T12:00:00Z")).toBe("3d ago"); + }); + + it("returns MM/DD/YYYY for > 7 days", () => { + expect(formatRelativeTime("2026-06-01T12:00:00Z")).toBe("06/01/2026"); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +Run: `npx vitest run src/lib/format-date.test.ts` +Expected: 6 tests, all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/format-date.ts src/lib/format-date.test.ts +git commit -m "feat(format): add formatRelativeTime (just now, Xm/Xh/Xd ago, MM/DD/YYYY)" +``` + +### Task 3.3: Create the Order detail page + +**Files:** +- Create: `src/app/admin/v2/orders/[id]/page.tsx` + +- [ ] **Step 1: Read the existing detail page** + +Run: `head -100 src/app/admin/orders/[id]/page.tsx` +Understand the data shape and existing status-change action. + +- [ ] **Step 2: Create the v2 detail page** + +```typescript +// src/app/admin/v2/orders/[id]/page.tsx +import { notFound } from "next/navigation"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { getOrderDetail } from "@/actions/orders"; +import { formatDate } from "@/lib/format-date"; +import PageHeader from "@/components/admin/PageHeader"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import StickyActionBar from "@/components/admin/StickyActionBar"; +import OrderActionButtons from "@/components/admin/orders/OrderActionButtons"; + +export const dynamic = "force-dynamic"; + +export default async function OrderDetailV2Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const adminUser = await getAdminUser(); + if (!adminUser) return null; + const order = await getOrderDetail(id); + if (!order) notFound(); + + return ( +
    + +
    +
    +

    {order.customer_name}

    +
    + {order.customer_phone && ( + + {order.customer_phone} + + )} + {order.customer_email && ( + + {order.customer_email} + + )} +
    +
    + +
    +
    + +
    +

    + ITEMS +

    +
      + {order.items.map((item) => ( +
    • + {item.image_url && ( + + )} +
      +
      {item.name}
      +
      + {item.quantity} × ${(item.price_cents / 100).toFixed(2)} +
      +
      +
      + ${((item.price_cents * item.quantity) / 100).toFixed(2)} +
      +
    • + ))} +
    +
    + TOTAL + ${(order.total_cents / 100).toFixed(2)} +
    +
    + + {order.notes && ( +
    +

    "{order.notes}"

    +
    + )} +
    + + + + +
    + ); +} +``` + +- [ ] **Step 3: Create the `OrderActionButtons` client component** + +```tsx +// src/components/admin/orders/OrderActionButtons.tsx +"use client"; + +import { useState } from "react"; +import { enqueueAction } from "@/lib/offline/queue"; +import { syncPending } from "@/lib/offline/sync"; +import { dispatchClientAction } from "@/actions/offline-dispatcher"; + +interface OrderActionButtonsProps { + orderId: string; + status: "placed" | "ready" | "picked-up" | "cancelled"; +} + +export function OrderActionButtons({ orderId, status }: OrderActionButtonsProps) { + const [pending, setPending] = useState(false); + const [conflict, setConflict] = useState(null); + + async function handleAction(actionName: string) { + setPending(true); + setConflict(null); + try { + await enqueueAction(actionName, { orderId }); + const result = await syncPending( + (name, payload, id) => dispatchClientAction(name, payload, id), + { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } + ); + if (result.conflicts > 0) setConflict("This order changed elsewhere. Refresh to see the latest."); + } finally { + setPending(false); + } + } + + if (status === "placed") { + return ( + + ); + } + if (status === "ready") { + return ( + + ); + } + if (conflict) { + return ( +
    {conflict}
    + ); + } + return ( + + View Receipt + + ); +} + +export default OrderActionButtons; +``` + +- [ ] **Step 4: Verify** + +Run: `npm run dev`, navigate to `/admin/v2/orders/`. +Expected: Detail view with header, items, notes (if any), sticky action bar at bottom. The "Mark Ready" or "Mark Picked Up" button should work online and queue offline. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/admin/v2/orders/[id]/page.tsx src/components/admin/orders/OrderActionButtons.tsx +git commit -m "feat(admin): add v2 order detail with StickyActionBar + offline queue integration" +``` + +### Task 3.4: Write the Orders list Playwright spec + +**Files:** +- Create: `tests/mobile-admin/orders-list.spec.ts` + +- [ ] **Step 1: Create the test** + +```typescript +// tests/mobile-admin/orders-list.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ viewport: { width: 390, height: 844 } }); + +test.beforeEach(async ({ page }) => { + // Sign in as a test admin (use seed data) + await page.goto("/login"); + await page.fill('input[name="email"]', "test-admin@example.com"); + await page.fill('input[name="password"]', "test-password"); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin/); +}); + +test("orders list renders without horizontal scroll on iPhone 13", async ({ page }) => { + await page.goto("/admin/v2/orders"); + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth); +}); + +test("orders list has no touch targets smaller than 48pt", async ({ page }) => { + await page.goto("/admin/v2/orders"); + const violations = await page.evaluate(() => { + const interactive = document.querySelectorAll("a, button, input, select, textarea, [role='button']"); + const small: string[] = []; + interactive.forEach((el) => { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0 && (rect.height < 48 || rect.width < 48)) { + small.push(`${el.tagName}.${(el as HTMLElement).className}: ${rect.width}x${rect.height}`); + } + }); + return small; + }); + expect(violations, `Found ${violations.length} small touch targets: ${violations.join(", ")}`).toHaveLength(0); +}); +``` + +- [ ] **Step 2: Run** + +Run: `npx playwright test tests/mobile-admin/orders-list.spec.ts` +Expected: 2 tests, all PASS (assuming the seed admin and some orders exist; if not, seed first via the project's admin seed script). + +- [ ] **Step 3: Commit** + +```bash +git add tests/mobile-admin/orders-list.spec.ts +git commit -m "test(admin): add v2 orders list mobile spec (no horizontal scroll, 48pt tap targets)" +``` + +### Task 3.5: Write the offline-queue Playwright spec + +**Files:** +- Create: `tests/mobile-admin/offline-queue.spec.ts` + +- [ ] **Step 1: Create the test** + +```typescript +// tests/mobile-admin/offline-queue.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ viewport: { width: 390, height: 844 } }); + +test.beforeEach(async ({ page }) => { + await page.goto("/login"); + await page.fill('input[name="email"]', "test-admin@example.com"); + await page.fill('input[name="password"]', "test-password"); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin/); +}); + +test("marking an order ready offline queues and syncs on reconnect", async ({ page, context }) => { + // Find a "placed" order from the v2 list + await page.goto("/admin/v2/orders"); + const placedOrderLink = page.locator('a:has(span:has-text("Placed"))').first(); + await placedOrderLink.click(); + await page.waitForURL(/\/admin\/v2\/orders\/[a-f0-9-]+/); + + // Go offline + await context.setOffline(true); + + // Click "Mark Ready" + const markReady = page.locator('button:has-text("Mark Ready")'); + await expect(markReady).toBeVisible(); + await markReady.click(); + + // Optimistic UI: the page should still update (or the button should disable) + // and the StickyActionBar should show "Pending sync" + await expect(page.locator('text="Pending sync"')).toBeVisible({ timeout: 5000 }); + + // Reconnect + await context.setOffline(false); + + // Wait up to 10s for sync + await expect(page.locator('text="Pending sync"')).toBeHidden({ timeout: 10000 }); +}); +``` + +- [ ] **Step 2: Run** + +Run: `npx playwright test tests/mobile-admin/offline-queue.spec.ts` +Expected: 1 test, PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/mobile-admin/offline-queue.spec.ts +git commit -m "test(admin): add offline mutation queue spec (mark ready offline, sync on reconnect)" +``` + +### Task 3.6: Add visual regression baselines for Orders v2 + +**Files:** +- Create: `tests/mobile-admin/orders-visual.spec.ts` + +- [ ] **Step 1: Create the test** + +```typescript +// tests/mobile-admin/orders-visual.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ viewport: { width: 390, height: 844 } }); + +test.beforeEach(async ({ page }) => { + await page.goto("/login"); + await page.fill('input[name="email"]', "test-admin@example.com"); + await page.fill('input[name="password"]', "test-password"); + await page.click('button[type="submit"]'); + await page.waitForURL(/\/admin/); +}); + +test("orders list — iPhone 13", async ({ page }) => { + await page.goto("/admin/v2/orders"); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveScreenshot("orders-list-iphone13.png", { fullPage: true, maxDiffPixels: 200 }); +}); + +test("order detail — iPhone 13", async ({ page }) => { + await page.goto("/admin/v2/orders"); + const firstOrder = page.locator('a[href*="/admin/v2/orders/"]').first(); + await firstOrder.click(); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveScreenshot("order-detail-iphone13.png", { fullPage: true, maxDiffPixels: 200 }); +}); +``` + +- [ ] **Step 2: Run to generate baselines** + +Run: `npx playwright test tests/mobile-admin/orders-visual.spec.ts --update-snapshots` +Expected: 2 snapshots created. + +- [ ] **Step 3: Commit** + +```bash +git add tests/mobile-admin/orders-visual.spec.ts tests/mobile-admin/__snapshots__/ +git commit -m "test(admin): add visual regression baselines for orders v2 (iPhone 13)" +``` + +### Task 3.7: Manual QA checklist for Orders v2 + +- [ ] **Step 1: Walk through the manual QA checklist** + +Open [docs/pwa-testing.md](../../pwa-testing.md) and follow the manual checklist. Specifically: +- Read `/admin/v2/orders` in direct sunlight or under a 5000K lamp +- Tap every interactive element while wearing gloves +- Verify the offline flow: toggle airplane mode, mark an order ready, observe "Pending sync", reconnect, observe sync + +- [ ] **Step 2: Document findings** + +Append your findings to `docs/pwa-testing.md`. If anything doesn't meet HIG or the design spec, file an issue or follow-up PR. + +--- + +## PR 4 — Stops v2 + +Same pattern as PR 3. Files: `src/app/admin/v2/stops/page.tsx` and `src/app/admin/v2/stops/[id]/page.tsx` (if a per-stop detail is needed; otherwise the list is enough). + +### Task 4.1: Create the Stops list page + +**Files:** +- Create: `src/app/admin/v2/stops/page.tsx` + +- [ ] **Step 1: Read the existing stops page** + +Run: `head -80 src/app/admin/stops/page.tsx` +Identify the data shape and the `getStopsForBrand` action (or equivalent). + +- [ ] **Step 2: Create the v2 page** + +```typescript +// src/app/admin/v2/stops/page.tsx +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { getStopsForBrand } from "@/actions/stops"; +import PageHeader from "@/components/admin/PageHeader"; +import { CardList, CardListItem } from "@/components/admin/CardList"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import EmptyState from "@/components/admin/EmptyState"; +import StopsDatePicker from "@/components/admin/stops/StopsDatePicker"; + +export const dynamic = "force-dynamic"; + +function groupByTimeOfDay(stops: Array<{ id: string; scheduled_at: string; address: string; status: string; customer_count: number; maps_url?: string }>) { + const groups: Record = { Morning: [], Afternoon: [], Evening: [] }; + stops.forEach((s) => { + const hour = new Date(s.scheduled_at).getHours(); + const bucket = hour < 12 ? "Morning" : hour < 17 ? "Afternoon" : "Evening"; + groups[bucket].push(s); + }); + return groups; +} + +function formatTime(iso: string) { + return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); +} + +function formatDateHeading(d: Date) { + return d.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }); +} + +export default async function StopsV2Page({ searchParams }: { searchParams: Promise<{ date?: string }> }) { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + const brandId = (await getActiveBrandId(adminUser)) ?? undefined; + const params = await searchParams; + const date = params.date ? new Date(params.date) : new Date(); + const stops = await getStopsForBrand(brandId!, { date }); + + if (stops.length === 0) { + return ( +
    + } /> + +
    + ); + } + + const groups = groupByTimeOfDay(stops); + return ( +
    + } /> + {Object.entries(groups).map(([time, groupStops]) => groupStops.length > 0 && ( +
    +

    + {time.toUpperCase()} +

    + + {groupStops.map((stop) => ( + +
    +
    + {formatTime(stop.scheduled_at)} +
    +
    +
    {stop.address}
    +
    + {stop.customer_count} customer{stop.customer_count === 1 ? "" : "s"} +
    +
    + +
    +
    + ))} +
    +
    + ))} +
    + ); +} +``` + +- [ ] **Step 2.1: Create the `StopsDatePicker` component** + +```tsx +// src/components/admin/stops/StopsDatePicker.tsx +"use client"; + +import { useRouter } from "next/navigation"; + +interface StopsDatePickerProps { + value: Date; +} + +function toInputValue(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export function StopsDatePicker({ value }: StopsDatePickerProps) { + const router = useRouter(); + return ( + + ); +} + +export default StopsDatePicker; +``` + +- [ ] **Step 2.2: Verify the server action signature** + +Open `src/actions/stops.ts` and confirm `getStopsForBrand` accepts `(brandId, { date })`. Adjust the call site to match the real signature. + +- [ ] **Step 3: Verify + commit** + +```bash +npm run dev +# Navigate to /admin/v2/stops, confirm card list renders +git add src/app/admin/v2/stops/page.tsx +git commit -m "feat(admin): add v2 stops list (time-grouped cards with status pill)" +``` + +### Task 4.2: Add visual regression baseline + +```bash +# Add a test in tests/mobile-admin/orders-visual.spec.ts (rename to admin-visual.spec.ts) for stops +git add tests/mobile-admin/ +git commit -m "test(admin): add v2 stops visual baseline" +``` + +--- + +## PR 5 — Products v2 + +### Task 5.1: Create the Products list page + +**Files:** +- Create: `src/app/admin/v2/products/page.tsx` + +- [ ] **Step 1: Create the page** + +```typescript +// src/app/admin/v2/products/page.tsx +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { getProductsForBrand } from "@/actions/products"; +import PageHeader from "@/components/admin/PageHeader"; +import { CardList, CardListItem } from "@/components/admin/CardList"; +import StatusPill, { type StatusKind } from "@/components/admin/StatusPill"; +import EmptyState from "@/components/admin/EmptyState"; +import ProductsFilterChips from "@/components/admin/products/ProductsFilterChips"; +import StockAdjustButton from "@/components/admin/products/StockAdjustButton"; + +export const dynamic = "force-dynamic"; + +function stockStatus(stock: number): { kind: StatusKind; label: string } { + if (stock === 0) return { kind: "out", label: "Out of stock" }; + if (stock < 10) return { kind: "low", label: `⚠ ${stock} left` }; + return { kind: "in-stock", label: `${stock} in stock` }; +} + +export default async function ProductsV2Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + const brandId = (await getActiveBrandId(adminUser)) ?? undefined; + const params = await searchParams; + const products = await getProductsForBrand(brandId!, { + limit: 100, + filter: params.filter, // "all" | "in-stock" | "low" | "out" | "hidden" + }); + + if (products.length === 0) { + return ( +
    + + + +
    + ); + } + + return ( +
    + + + + {products.map((product) => { + const stock = stockStatus(product.stock); + return ( + +
    + {product.image_url && ( + + )} +
    +
    +
    {product.name}
    + +
    +
    ${(product.price_cents / 100).toFixed(2)}
    +
    + +
    +
    +
    +
    + ); + })} +
    +
    + ); +} +``` + +- [ ] **Step 1.1: Create the `ProductsFilterChips` component** + +```tsx +// src/components/admin/products/ProductsFilterChips.tsx +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +const FILTERS = [ + { value: "all", label: "All" }, + { value: "in-stock", label: "In stock" }, + { value: "low", label: "Low" }, + { value: "out", label: "Out" }, + { value: "hidden", label: "Hidden" }, +]; + +export function ProductsFilterChips({ active }: { active: string }) { + const router = useRouter(); + const searchParams = useSearchParams(); + + function apply(value: string) { + const params = new URLSearchParams(searchParams.toString()); + if (value === "all") params.delete("filter"); + else params.set("filter", value); + router.push(`/admin/v2/products?${params.toString()}`); + } + + return ( +
    + {FILTERS.map((f) => { + const isActive = active === f.value; + return ( + + ); + })} +
    + ); +} + +export default ProductsFilterChips; +``` + +- [ ] **Step 1.2: Create the `StockAdjustButton` component (long-press to open stepper)** + +```tsx +// src/components/admin/products/StockAdjustButton.tsx +"use client"; + +import { useRef, useState } from "react"; +import { enqueueAction } from "@/lib/offline/queue"; +import { syncPending } from "@/lib/offline/sync"; +import { dispatchClientAction } from "@/actions/offline-dispatcher"; + +interface StockAdjustButtonProps { + productId: string; + currentStock: number; +} + +const LONG_PRESS_MS = 500; + +export function StockAdjustButton({ productId, currentStock }: StockAdjustButtonProps) { + const [open, setOpen] = useState(false); + const [stock, setStock] = useState(currentStock); + const [pending, setPending] = useState(false); + const timer = useRef | null>(null); + + function onPointerDown() { + timer.current = setTimeout(() => setOpen(true), LONG_PRESS_MS); + } + function onPointerUp() { + if (timer.current) clearTimeout(timer.current); + } + + async function adjust(delta: number) { + const next = stock + delta; + if (next < 0) return; + setStock(next); + setPending(true); + try { + await enqueueAction("adjustProductStock", { productId, delta }); + await syncPending( + (name, payload, id) => dispatchClientAction(name, payload, id), + { online: navigator.onLine, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) } + ); + } finally { + setPending(false); + } + } + + return ( + <> + + {open && ( +
    +
    +
    +

    Adjust stock

    +
    + +
    {stock}
    + +
    + +
    +
    + )} + + ); +} + +export default StockAdjustButton; +``` + +- [ ] **Step 1.3: Verify the server action signature** + +Open `src/actions/products.ts` and confirm `getProductsForBrand` accepts `(brandId, { limit, filter })`. Adjust to match the real signature. If the existing action doesn't accept a `filter` parameter, either: +1. Add one to the action (preferred — it's a small, useful addition) +2. Filter client-side from `ProductsV2Page` before rendering + +- [ ] **Step 2: Verify + commit** + +```bash +npm run dev +git add src/app/admin/v2/products/page.tsx src/components/admin/products/ProductsFilterChips.tsx src/components/admin/products/StockAdjustButton.tsx +git commit -m "feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust)" +``` + +- [ ] **Step 2: Verify + commit** + +```bash +npm run dev +git add src/app/admin/v2/products/page.tsx +git commit -m "feat(admin): add v2 products list (image-forward cards with stock status)" +``` + +--- + +## PR 6 — Cutover Orders + Stops + Products + +### Task 6.1: Add redirects in `next.config.ts` + +**Files:** +- Modify: `next.config.ts` + +- [ ] **Step 1: Add redirects** + +In the `next.config.ts` `module.exports` (or however the config is shaped), add: + +```typescript +async redirects() { + return [ + { source: "/admin/orders", destination: "/admin/v2/orders", permanent: false }, + { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, + { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, + { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, + ]; +}, +``` + +- [ ] **Step 2: Verify** + +Run: `npm run dev`, navigate to `/admin/orders` — should redirect to `/admin/v2/orders`. + +- [ ] **Step 3: Commit** + +```bash +git add next.config.ts +git commit -m "feat(admin): redirect /admin/{orders,stops,products} to /v2" +``` + +### Task 6.2: Delete the v1 routes + +**Files:** +- Delete: `src/app/admin/orders/` +- Delete: `src/app/admin/stops/` +- Delete: `src/app/admin/products/` + +⚠️ **Wait at least 3 days after the redirect ships** to catch any 404s in monitoring before deleting the v1 files. + +- [ ] **Step 1: Verify no 404s in production logs** + +Check the production error tracker (Sentry, etc.) for any 404s on `/admin/orders`, `/admin/orders/[id]`, `/admin/stops`, `/admin/products`. If clean for 3 days, proceed. + +- [ ] **Step 2: Delete the directories** + +```bash +rm -rf src/app/admin/orders src/app/admin/stops src/app/admin/products +``` + +- [ ] **Step 3: Verify build still passes** + +Run: `npm run build` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore(admin): remove v1 orders/stops/products routes (replaced by v2)" +``` + +### Task 6.3: Update navigation in `MoreSheet` + +**Files:** +- Modify: `src/components/admin/MoreSheet.tsx` + +- [ ] **Step 1: Update the secondary nav links to point to v2 where they exist** + +(The `MoreSheet` currently links to v1 routes for Orders/Stops/Products. Update to `/admin/v2/...`.) + +```typescript +const MORE_SECTIONS = [ + { + heading: "Operations", + links: [ + { label: "Pickup", href: "/admin/pickup", icon: "📦" }, + { label: "Shipping", href: "/admin/shipping", icon: "🚚" }, + { label: "Reports", href: "/admin/reports", icon: "📊" }, + { label: "Analytics", href: "/admin/analytics", icon: "📈" }, + ], + }, + { + heading: "Marketing", + links: [ + { label: "Communications", href: "/admin/communications", icon: "📧" }, + { label: "Sales", href: "/admin/sales", icon: "💵" }, + ], + }, + { + heading: "Settings", + links: [ + { label: "Settings", href: "/admin/settings", icon: "⚙️" }, + { label: "Advanced", href: "/admin/advanced", icon: "🛠" }, + ], + }, +]; +``` + +(Only update Orders/Stops/Products links if they're listed here — they're not, since the bottom tab bar handles them.) + +- [ ] **Step 2: Commit** + +```bash +git add src/components/admin/MoreSheet.tsx +git commit -m "feat(admin): update MoreSheet links to v2 routes" +``` + +--- + +## PR 7 — Dashboard v2 + dashboard RPC migration + +### Task 7.1: Write the `get_dashboard_summary` RPC migration + +**Files:** +- Create: `db/migrations/NNN_dashboard_summary_rpc.sql` (use the next available number, e.g. 0202 if 0201 is the latest in production — check `MEMORY.md` or the prod DB) + +- [ ] **Step 1: Write the migration** + +```sql +-- db/migrations/NNN_dashboard_summary_rpc.sql +-- Dashboard summary RPC for the mobile-first admin + +CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + result JSONB; +BEGIN + SELECT jsonb_build_object( + 'orders_today', ( + SELECT COUNT(*) FROM orders + WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE + ), + 'revenue_today', ( + SELECT COALESCE(SUM(total_cents), 0) FROM orders + WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE + AND status NOT IN ('cancelled') + ), + 'pending_fulfillment', ( + SELECT COUNT(*) FROM orders + WHERE brand_id = p_brand_id AND status IN ('placed', 'ready') + ), + 'stops_today', ( + SELECT COUNT(*) FROM stops + WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE + ), + 'orders_last_7_days', ( + SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0))) + FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d + LEFT JOIN ( + SELECT created_at::date AS day, COUNT(*) AS cnt + FROM orders + WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days' + GROUP BY 1 + ) o ON o.day = d::date + ) + ) INTO result; + RETURN result; +END; +$$; +``` + +- [ ] **Step 2: Add the `total_cents` migration if it doesn't exist** + +If the `orders` table has `total` (in dollars) and not `total_cents` (in cents), add a migration: + +```sql +-- db/migrations/NNN_orders_total_cents.sql (only if needed) +ALTER TABLE orders ADD COLUMN IF NOT EXISTS total_cents INTEGER; +UPDATE orders SET total_cents = (total * 100)::INTEGER WHERE total_cents IS NULL; +ALTER TABLE orders ALTER COLUMN total_cents SET NOT NULL; +``` + +(Verify the actual column name in the orders table before running this. Per CLAUDE.md, the existing code uses RPCs, so `total_cents` may or may not exist.) + +- [ ] **Step 3: Apply locally and test** + +Run: `npm run migrate:one NNN` (with the actual number). +Expected: Migration applies cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add db/migrations/NNN_*.sql +git commit -m "feat(db): add get_dashboard_summary RPC and total_cents column if needed" +``` + +### Task 7.2: Create the v2 Dashboard page + +**Files:** +- Create: `src/app/admin/v2/page.tsx` + +- [ ] **Step 1: Create the page** + +```typescript +// src/app/admin/v2/page.tsx +import Link from "next/link"; +import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; +import { getDashboardSummary } from "@/actions/dashboard"; +import { getStopsForBrand } from "@/actions/stops"; +import { getOrdersForBrand } from "@/actions/orders"; +import PageHeader from "@/components/admin/PageHeader"; +import EmptyState from "@/components/admin/EmptyState"; +import { formatDate } from "@/lib/format-date"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardV2Page() { + const adminUser = await getAdminUser(); + if (!adminUser) return null; + const brandId = (await getActiveBrandId(adminUser)) ?? undefined; + if (!brandId) { + return ( +
    + + +
    + ); + } + const [summary, stops, pendingOrders] = await Promise.all([ + getDashboardSummary(brandId), + getStopsForBrand(brandId, { date: new Date() }), + getOrdersForBrand(brandId, { status: "placed", limit: 5 }), + ]); + + return ( +
    + +
    + {summary.pending_fulfillment > 0 && ( + +
    + NEEDS YOU +
    +
    + {summary.pending_fulfillment} order{summary.pending_fulfillment === 1 ? "" : "s"} waiting +
    +
    View all →
    + + )} + +
    + + + 0} /> + +
    + + {stops.length > 0 && ( +
    +

    + TODAY'S STOPS +

    +
      + {stops.slice(0, 5).map((stop) => ( +
    • +
      + {new Date(stop.scheduled_at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })} +
      +
      {stop.address}
      +
    • + ))} +
    +
    + )} +
    +
    + ); +} + +function StatCard({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) { + return ( +
    +
    + {label.toUpperCase()} +
    +
    + {value} +
    +
    + ); +} +``` + +> **Note on `pendingOrders`:** The `pendingOrders` variable is fetched but not rendered in this skeleton. It's available if you want to show the top 5 pending orders directly on the dashboard. To render them, add a `
    ` between "Needs you" and the stat row, listing the orders as small cards. Optional. + +- [ ] **Step 2: Add the `getDashboardSummary` action** + +Create `src/actions/dashboard.ts`: + +```typescript +// src/actions/dashboard.ts +"use server"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { pool } from "@/lib/db"; + +export interface DashboardSummary { + orders_today: number; + revenue_today: number; + pending_fulfillment: number; + stops_today: number; + orders_last_7_days: Array<{ date: string; count: number }>; +} + +export async function getDashboardSummary(brandId: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) throw new Error("Unauthorized"); + const result = await pool.query( + "SELECT get_dashboard_summary($1::uuid) AS data", + [brandId] + ); + return result.rows[0]?.data ?? { + orders_today: 0, revenue_today: 0, pending_fulfillment: 0, stops_today: 0, orders_last_7_days: [], + }; +} +``` + +(Verify the existing `pool` import path — per CLAUDE.md, `pg` is used and `pool` is exported from `src/lib/db.ts`. Adjust as needed.) + +- [ ] **Step 3: Verify** + +Run: `npm run dev`, navigate to `/admin/v2`. +Expected: 4 stat cards, "Today's stops" section. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/admin/v2/page.tsx src/actions/dashboard.ts +git commit -m "feat(admin): add v2 dashboard with stat cards and stops timeline" +``` + +--- + +## PR 8 — Dashboard cutover + +### Task 8.1: Redirect `/admin` to `/admin/v2` + +**Files:** +- Modify: `next.config.ts` + +- [ ] **Step 1: Add the redirect** + +```typescript +async redirects() { + return [ + { source: "/admin", destination: "/admin/v2", permanent: false }, + { source: "/admin/orders", destination: "/admin/v2/orders", permanent: false }, + { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, + { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, + { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, + ]; +}, +``` + +- [ ] **Step 2: Commit** + +```bash +git add next.config.ts +git commit -m "feat(admin): redirect /admin to /admin/v2" +``` + +### Task 8.2: Delete the v1 dashboard + +**Files:** +- Delete: `src/app/admin/page.tsx` +- Delete: `src/app/admin/loading.tsx` (if specific to the v1 dashboard) +- Modify: `src/app/admin/layout.tsx` to remove v1-specific logic (the v1 layout is now unreachable) + +⚠️ **Wait 3 days after the redirect ships** before deleting. + +- [ ] **Step 1: Verify no 404s, then delete** + +```bash +rm src/app/admin/page.tsx +# If the layout in src/app/admin/layout.tsx has v1-specific code that the v2 layout doesn't need, simplify it. +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add -A +git commit -m "chore(admin): remove v1 dashboard (replaced by v2)" +``` + +### Task 8.3: Final Lighthouse audit + manual sign-off + +- [ ] **Step 1: Run Lighthouse mobile audit** + +```bash +npx lighthouse http://localhost:3000/admin/v2 --preset=mobile --view --output=html --output-path=./lighthouse-report.html +``` + +Confirm: +- Performance ≥ 90 +- Accessibility ≥ 95 +- PWA: installable, manifest valid, SW registered, themed + +- [ ] **Step 2: Manual QA on a real device** + +Follow the full checklist in `docs/pwa-testing.md`. Sign off in the doc. + +- [ ] **Step 3: Commit the audit + sign-off** + +```bash +git add docs/pwa-testing.md +git commit -m "docs: sign off on mobile PWA rollout" +``` + +--- + +## Definition of done (re-stated) + +- [ ] All 4 key pages ship the mobile-first treatment at `/admin/v2/*` +- [ ] PWA installs on iOS Safari and Android Chrome (verified on real devices) +- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline.html` on navigation +- [ ] Offline mutation queue replays actions on reconnect with no data loss +- [ ] All interactive elements ≥ 48pt on mobile (aim 56pt) +- [ ] All text passes WCAG AAA (7:1) against its background (CI-enforced) +- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes +- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport +- [ ] `prefers-reduced-motion` honored +- [ ] All Playwright specs pass in CI +- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports +- [ ] Real-device install + offline testing documented and signed off