Files
route-commerce/docs/superpowers/plans/2026-06-17-admin-mobile-pwa.md
T
Tyler 837f7f83cb fix(admin): AdminShell brands type must include logo_url (AdminSidebar requires it)
The plan's AdminShell type for the brands prop was missing the
logo_url field that AdminSidebar requires. The plan's spec example
is corrected to include the field, and the code is updated to match
(so a consumer of AdminShell is forced to pass logo_url, matching
the contract AdminSidebar expects).

Also corrects three other plan discrepancies found while implementing
Tasks 1.7-1.12:
- Task 1.12: existing EmptyState.tsx is in active use; reuse it
  instead of creating a duplicate.
- Task 1.12: CardList.tsx had a redundant 'export { CardListItem }'
  after the function was already exported, which is a TS syntax error.
  Removed the redundant line.
- Plan narrative now reflects all three corrections.

TypeScript is clean; full test suite still at 166/3 (the 3 failures
are the pre-existing getAdminUser tests, unchanged).
2026-06-17 14:25:16 -06:00

4066 lines
133 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<dialog>` 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 — AAA on every surface except --text-faint (AA only, ≥ 5.28:1) */
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-text-faint: #5e5e63; /* AA on every surface (≥ 5.28:1) */
/* Accent / status — darkened to pass AAA on the surfaces they actually
* appear on (bg, surface, and their -soft pill backgrounds). The darker
* hues (green-900 / red-900 / amber-900) intentionally trade a touch of
* vibrancy for legibility in outdoor / sun-glare conditions. */
--color-accent: #14532d; /* AAA on bg (9.11), surface (8.59), s2 (8.37), s3 (7.46) */
--color-accent-2: #166534; /* hover state — used as button background, NOT text */
--color-accent-soft: #dcfce7; /* pill background; accent text on it = 8.36:1 */
--color-danger: #7f1d1d; /* AAA on bg (10.02), surface (9.45), s2 (9.20), s3 (8.20) */
--color-danger-soft: #fee2e2;
--color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */
--color-warning-soft: #fef9c3;
--color-success: #14532d; /* mirrors accent */
--color-success-soft: #dcfce7;
--color-info: #1e3a8a; /* AAA on every surface (8.4810.36) */
--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 → `<html>` → 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 for the
* surfaces they actually render on. Rationale: outdoor + sun-glare is
* harder than WCAG accounts for, so we aim for AAA (7:1) on the
* surfaces text appears on, and AA (4.5:1) as the documented floor.
*
* Surface roles:
* bg — page background
* surface — card / panel background
* surface-2 — input fields, secondary cards
* surface-3 — high-emphasis containers, skeletons, dividers
*
* Token roles (and which assertion groups they belong to):
* text / text-muted — body text, appears on all 4 surfaces → AAA
* text-faint — meta / timestamps, appears on all 4 surfaces → AA
* accent / success — brand + positive status text → AAA on bg, surface
* danger / warning / info — status text, on bg + surface + their -soft pill bg → AAA
* accent-2 — button background, white text sits on top → AAA
* *-soft — pill background, distinct from page surface (decorative)
*/
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<string, string> = {
bg: "#ffffff",
surface: "#faf8f5",
"surface-2": "#f5f5f7",
"surface-3": "#e8e8ed",
};
// Page surfaces that status text actually renders on. Status text does
// not render directly on --color-surface-3 (skeleton/dividers); it lives
// on its own -soft pill background instead.
const PAGE_SURFACES: Record<string, string> = {
bg: "#ffffff",
surface: "#faf8f5",
};
const BODY_TEXT_AAA: Record<string, string> = {
text: "#1d1d1f",
"text-muted": "#424245",
info: "#1e3a8a",
};
const META_TEXT_AA: Record<string, string> = {
"text-faint": "#5e5e63",
};
const STATUS_TEXT_AAA: Record<string, string> = {
accent: "#14532d",
danger: "#7f1d1d",
warning: "#5e2a04",
success: "#14532d",
};
const STATUS_ON_SOFT: Record<string, { text: string; soft: string }> = {
"accent on accent-soft": { text: "#14532d", soft: "#dcfce7" },
"danger on danger-soft": { text: "#7f1d1d", soft: "#fee2e2" },
"warning on warning-soft": { text: "#5e2a04", soft: "#fef9c3" },
"success on success-soft": { text: "#14532d", soft: "#dcfce7" },
"info on info-soft": { text: "#1e3a8a", soft: "#dbeafe" },
};
const ON_ACCENT_BUTTON = {
fg: "#ffffff", // button label
bg: "#166534", // --color-accent-2
};
describe("design tokens — WCAG contrast", () => {
describe("body text on every page surface (AAA)", () => {
Object.entries(BODY_TEXT_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);
});
});
});
});
describe("meta text on every page surface (AA)", () => {
Object.entries(META_TEXT_AA).forEach(([token, hex]) => {
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
it(`${token} on ${surfaceName} meets AA (>= 4.5:1)`, () => {
const ratio = contrastRatio(hex, surfaceHex);
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
});
});
});
});
describe("status text on page surfaces (AAA)", () => {
Object.entries(STATUS_TEXT_AAA).forEach(([token, hex]) => {
Object.entries(PAGE_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);
});
});
});
});
describe("status text on its -soft pill background (AAA)", () => {
Object.entries(STATUS_ON_SOFT).forEach(([name, { text, soft }]) => {
it(`${name} meets AAA (>= 7:1)`, () => {
const ratio = contrastRatio(text, soft);
expect(ratio, `${name}: ${text} on ${soft} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
});
});
});
describe("button text on accent-2 background (AAA)", () => {
it(`white on accent-2 meets AAA (>= 7:1)`, () => {
const ratio = contrastRatio(ON_ACCENT_BUTTON.fg, ON_ACCENT_BUTTON.bg);
expect(ratio, `white on ${ON_ACCENT_BUTTON.bg} = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
});
});
describe("pill backgrounds are visually distinct from page surface", () => {
// The "soft" backgrounds are decorative. They don't need to pass
// contrast on their own, but they must read as a distinct fill
// against the page surface.
const softs: Record<string, string> = {
"accent-soft": "#dcfce7",
"danger-soft": "#fee2e2",
"warning-soft": "#fef9c3",
"success-soft": "#dcfce7",
"info-soft": "#dbeafe",
};
Object.entries(softs).forEach(([token, hex]) => {
it(`${token} reads as a distinct fill on --color-surface`, () => {
const ratio = contrastRatio(hex, "#faf8f5");
expect(ratio, `${token} too close to surface (${ratio.toFixed(2)}:1)`).toBeLessThan(1.5);
});
});
});
});
```
- [ ] **Step 2: Run the test**
Run: `npx vitest run src/lib/__tests__/design-tokens.test.ts`
Expected: 35 tests, all PASS. (3 body text AAA × 4 surfaces = 12, plus 1 meta text AA × 4 surfaces = 4, plus 4 status text AAA × 2 page surfaces = 8, plus 5 status-on-soft AAA = 5, plus 1 button-text AAA = 1, plus 5 soft-distinct = 5, total = 35. If a real product surface is missing from the matrix, add it to `SURFACES` or `PAGE_SURFACES`; 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<IDBPDatabase<RouteCommerceOfflineDB>> | null = null;
export function getOfflineDB(): Promise<IDBPDatabase<RouteCommerceOfflineDB>> {
if (typeof window === "undefined") {
throw new Error("getOfflineDB called on the server");
}
if (!dbPromise) {
dbPromise = openDB<RouteCommerceOfflineDB>(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<QueuedAction> {
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<QueuedAction[]> {
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<QueuedAction | null> {
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<void> {
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<void> {
const db = await getOfflineDB();
await db.delete("queue", id);
}
export async function markConflict(id: string, error: string): Promise<void> {
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<void> {
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<void>;
now?: () => number;
}
export interface SyncResult {
synced: number;
conflicts: number;
failed: number;
}
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
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 (
<div
role="status"
aria-live="polite"
className="sticky top-0 z-40 w-full px-4 py-2 text-sm font-semibold"
style={{
backgroundColor: online ? "var(--color-warning-soft)" : "var(--color-danger-soft)",
color: online ? "var(--color-warning)" : "var(--color-danger)",
}}
>
{online ? (
<>Syncing {pendingCount} pending action{pendingCount === 1 ? "" : "s"}</>
) : (
<>You're offline. Changes will sync when you reconnect.</>
)}
</div>
);
}
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; logo_url: string | null }[];
enabledAddons?: Record<string, boolean>;
children: ReactNode;
}
export function AdminShell(props: AdminShellProps) {
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop) {
return (
<>
<AdminSidebar
userRole={props.userRole}
brandIds={props.brandIds}
activeBrandId={props.activeBrandId}
brands={props.brands}
enabledAddons={props.enabledAddons}
/>
<div
id="page-content"
className="min-h-screen lg:pl-60 outline-none"
style={{ backgroundColor: "var(--color-surface)" }}
>
{props.children}
</div>
</>
);
}
return (
<>
<OfflineBanner />
<div
id="page-content"
className="min-h-screen pb-20 outline-none"
style={{ backgroundColor: "var(--color-surface)" }}
>
{props.children}
</div>
<MobileTabBar />
</>
);
}
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<boolean>(() => {
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 (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
<path d="M3 12L12 3l9 9M5 10v10h14V10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function OrdersIcon({ active }: { active: boolean }) {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
<path d="M3 3h2l2 13h12l2-9H6" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="9" cy="20" r="1.5" /><circle cx="18" cy="20" r="1.5" />
</svg>
);
}
function StopsIcon({ active }: { active: boolean }) {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
<path d="M12 22s8-7.5 8-13a8 8 0 10-16 0c0 5.5 8 13 8 13z" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="9" r="2.5" />
</svg>
);
}
function ProductsIcon({ active }: { active: boolean }) {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2}>
<path d="M3 7l9-4 9 4v10l-9 4-9-4V7z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 7l9 4 9-4M12 11v10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function MobileTabBar() {
const pathname = usePathname();
const [moreOpen, setMoreOpen] = useState(false);
return (
<>
<nav
aria-label="Primary"
className="fixed bottom-0 left-0 right-0 z-30 grid grid-cols-5 border-t"
style={{
height: "60px",
backgroundColor: "var(--color-bg)",
borderColor: "var(--color-surface-3)",
}}
>
{TABS.map((tab) => {
const active = pathname === tab.href || pathname?.startsWith(tab.href + "/");
return (
<Link
key={tab.href}
href={tab.href}
className="flex flex-col items-center justify-center gap-0.5 active:scale-95 transition-transform"
style={{ color: active ? "var(--color-accent)" : "var(--color-text-muted)" }}
>
<tab.icon active={active} />
<span className="text-[12px] font-semibold" style={{ letterSpacing: "0.02em" }}>{tab.label}</span>
</Link>
);
})}
<button
type="button"
onClick={() => setMoreOpen(true)}
className="flex flex-col items-center justify-center gap-0.5 active:scale-95 transition-transform"
style={{ color: "var(--color-text-muted)" }}
aria-label="Open more menu"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle cx="19" cy="12" r="2" />
</svg>
<span className="text-[12px] font-semibold" style={{ letterSpacing: "0.02em" }}>More</span>
</button>
</nav>
<MoreSheet open={moreOpen} onClose={() => 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<HTMLDialogElement>(null);
const lastFocusedRef = useRef<HTMLElement | null>(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 (
<dialog
ref={dialogRef}
onClick={(e) => {
// 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)",
}}
>
<div className="flex flex-col h-full overflow-y-auto">
<div className="sticky top-0 px-6 py-4 border-b" style={{ backgroundColor: "var(--color-bg)", borderColor: "var(--color-surface-3)" }}>
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>More</h2>
</div>
<div className="px-6 py-6 space-y-8">
{MORE_SECTIONS.map((section) => (
<section key={section.heading}>
<h3 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
{section.heading.toUpperCase()}
</h3>
<ul className="space-y-1">
{section.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={onClose}
className="flex items-center gap-3 px-4 py-4 rounded-xl active:bg-[var(--color-surface-2)] transition-colors"
style={{ minHeight: "56px" }}
>
<span aria-hidden="true" className="text-2xl">{link.icon}</span>
<span className="text-h2" style={{ fontWeight: 700 }}>{link.label}</span>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</dialog>
);
}
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/CardList.tsx`
- Create: `src/components/admin/StickyActionBar.tsx`
> **Note:** `EmptyState.tsx` already exists in the repo and is in active use by `DashboardClient.tsx` and `ProductsClient.tsx` (re-exported via `design-system/index.tsx`). Reusing that component in the new mobile shell — do not create a duplicate.
- [ ] **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 (
<header className="px-4 pt-6 pb-4 flex items-start justify-between gap-4">
<div>
<h1 className="text-display font-display" style={{ fontWeight: 600, lineHeight: 1.15, letterSpacing: "-0.01em" }}>
{title}
</h1>
{subtitle && (
<p className="text-mono mt-1" style={{ color: "var(--color-text-muted)" }}>{subtitle}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</header>
);
}
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<StatusKind, { bg: string; fg: string; label: string }> = {
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 (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-label font-semibold"
style={{ backgroundColor: cfg.bg, color: cfg.fg, letterSpacing: "0.02em", minHeight: "24px" }}
aria-label={cfg.label}
>
{label ?? cfg.label}
</span>
);
}
export default StatusPill;
```
- [ ] **Step 3: `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 (
<ul role="list" aria-label={ariaLabel} className="space-y-3 px-4 pb-24">
{children}
</ul>
);
}
interface CardListItemProps {
onClick?: () => void;
href?: string;
children: ReactNode;
as?: "li" | "div";
}
export function CardListItem({ onClick, href, children, as = "li" }: CardListItemProps) {
const content = (
<div
className="rounded-2xl p-4 transition-colors"
style={{
backgroundColor: "var(--color-surface-2)",
minHeight: "72px",
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
}}
>
{children}
</div>
);
if (href) {
return (
<li>
<a href={href} className="block active:scale-[0.99] transition-transform" style={{ minHeight: "72px" }}>
{content}
</a>
</li>
);
}
if (onClick) {
return (
<li>
<button
type="button"
onClick={onClick}
className="block w-full text-left active:scale-[0.99] transition-transform"
style={{ minHeight: "72px" }}
>
{content}
</button>
</li>
);
}
return <li>{content}</li>;
}
```
- [ ] **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 (
<div
className="fixed left-0 right-0 z-20 px-4 py-3 border-t"
style={{
bottom: "60px", // sits above the mobile tab bar
backgroundColor: "var(--color-bg)",
borderColor: "var(--color-surface-3)",
boxShadow: "0 -4px 12px rgba(0,0,0,0.04)",
}}
>
{pendingSync && (
<div className="text-label font-semibold mb-2 flex items-center gap-2" style={{ color: "var(--color-text-muted)" }}>
<span className="inline-block w-3 h-3 rounded-full animate-pulse" style={{ backgroundColor: "var(--color-warning)" }} />
Pending sync
</div>
)}
{children}
</div>
);
}
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/CardList.tsx src/components/admin/StickyActionBar.tsx
git commit -m "feat(admin): add PageHeader, StatusPill, 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.43.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(`<svg width="1280" height="720" xmlns="http://www.w3.org/2000/svg">
<text x="640" y="360" font-family="serif" font-size="48" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
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(`<svg width="390" height="844" xmlns="http://www.w3.org/2000/svg">
<text x="195" y="422" font-family="serif" font-size="32" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#166534">
<title>Offline — Route Commerce</title>
<style>
:root {
--color-bg: #ffffff;
--color-surface: #faf8f5;
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-accent: #166534;
--color-warning: #854d0e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
background: var(--color-surface);
color: var(--color-text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
}
.card {
max-width: 480px;
width: 100%;
background: var(--color-bg);
border-radius: 24px;
padding: 48px 32px;
text-align: center;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
background: var(--color-warning);
color: white;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
h1 {
font-family: Fraunces, Georgia, serif;
font-size: 32px;
font-weight: 600;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
p {
font-size: 18px;
line-height: 1.45;
color: var(--color-text-muted);
margin: 0 0 24px;
}
button {
background: var(--color-accent);
color: white;
border: 0;
border-radius: 12px;
padding: 16px 32px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
min-height: 56px;
}
button:active { opacity: 0.85; }
</style>
</head>
<body>
<div class="card" role="status">
<div class="icon" aria-hidden="true">📡</div>
<h1>You're offline</h1>
<p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
<button type="button" onclick="location.reload()">Try again</button>
</div>
</body>
</html>
```
- [ ] **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 `<head>`**
After the `<html>` 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 `<head>` directly):
In the `metadata` export, add an `appLinks` and `manifest` field. Then in the `<head>` of the body, add the apple-specific tags via a small `<head>` 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 `<link rel="manifest" href="/manifest.json">` and `<meta name="apple-mobile-web-app-capable" content="yes">` 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
<PWAInstallPrompt />
```
- [ ] **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 (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
export default async function AdminV2Layout({ children }: { children: React.ReactNode }) {
let adminUser = null;
try {
adminUser = await getAdminUser();
} catch (e) {
return <ToastProviderWrapper><AdminAccessDenied message="Failed to verify authentication." /></ToastProviderWrapper>;
}
if (!adminUser) {
return <ToastProviderWrapper><AdminAccessDenied message="You don't have admin access." /></ToastProviderWrapper>;
}
const activeBrandId = await getActiveBrandId(adminUser).catch(() => null);
const brands = await listBrandsForAdmin().catch(() => []);
const enabledAddons = activeBrandId ? await getEnabledAddons(activeBrandId).catch(() => ({})) : {};
return (
<ToastProviderWrapper>
<AdminShell
userRole={adminUser.role}
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
enabledAddons={enabledAddons}
>
<SmoothViewTransition>{children}</SmoothViewTransition>
<RouteAnnouncer />
</AdminShell>
</ToastProviderWrapper>
);
}
```
- [ ] **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 (
<main>
<PageHeader title="Orders" actions={<OrdersFilterButton />} />
<EmptyState title="No orders yet" description="When customers place orders, they'll show up here." icon="📦" />
</main>
);
}
return (
<main>
<PageHeader title="Orders" subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`} actions={<OrdersFilterButton />} />
<PullToRefresh>
<CardList ariaLabel="Orders">
{orders.map((order) => (
<CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<StatusPill status={order.status as StatusKind} />
<span className="text-mono" style={{ color: "var(--color-text-faint)" }}>#{order.id.slice(0, 8)}</span>
<span className="text-meta ml-auto" style={{ color: "var(--color-text-muted)" }}>{formatRelativeTime(order.created_at)}</span>
</div>
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{order.customer_name ?? "Customer"}</div>
<div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>{order.item_count} item{order.item_count === 1 ? "" : "s"}</div>
<div className="flex items-center justify-between mt-2">
<span className="text-h1 font-display" style={{ fontWeight: 600 }}>
${(order.total_cents / 100).toFixed(2)}
</span>
<span aria-hidden="true" style={{ color: "var(--color-text-faint)" }}></span>
</div>
</CardListItem>
))}
</CardList>
</PullToRefresh>
</main>
);
}
```
> **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<HTMLDivElement>(null);
const startY = useRef<number | null>(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 (
<div
ref={containerRef}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={{
transform: `translateY(${offset}px)`,
transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
overscrollBehavior: "contain",
}}
>
{offset > 0 && (
<div
aria-hidden="true"
className="flex items-center justify-center"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: `${REFRESH_INDICATOR_HEIGHT}px`,
transform: `translateY(-${REFRESH_INDICATOR_HEIGHT}px)`,
}}
>
<div
className="rounded-full"
style={{
width: "32px",
height: "32px",
border: "3px solid var(--color-surface-3)",
borderTopColor: "var(--color-accent)",
animation: refreshing ? "spin 0.8s linear infinite" : "none",
}}
/>
</div>
)}
{children}
</div>
);
}
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<HTMLDialogElement>(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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Filter orders"
className="rounded-xl flex items-center gap-2"
style={{
minHeight: "56px",
padding: "0 16px",
backgroundColor: "var(--color-surface-2)",
color: "var(--color-text)",
fontWeight: 600,
letterSpacing: "0.02em",
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M6 12h12M10 18h4" strokeLinecap="round" />
</svg>
Filter
</button>
<dialog
ref={dialogRef}
onClick={(e) => { 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)" }}
>
<div className="flex flex-col h-full overflow-y-auto">
<div className="sticky top-0 px-6 py-4 border-b flex items-center justify-between" style={{ backgroundColor: "var(--color-bg)", borderColor: "var(--color-surface-3)" }}>
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Filter</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-2"></button>
</div>
<div className="px-6 py-6 space-y-3">
{["", "placed", "ready", "picked-up", "cancelled"].map((s) => (
<button
key={s || "all"}
type="button"
onClick={() => apply(s)}
className="w-full text-left rounded-xl px-4 py-4"
style={{
minHeight: "56px",
backgroundColor: currentStatus === s ? "var(--color-accent-soft)" : "var(--color-surface-2)",
fontWeight: 600,
}}
>
{s ? s.charAt(0).toUpperCase() + s.slice(1) : "All orders"}
</button>
))}
</div>
</div>
</dialog>
</>
);
}
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 (
<main className="pb-40">
<PageHeader title={`Order #${order.id.slice(0, 8)}`} subtitle={formatDate(order.created_at)} />
<div className="px-4 space-y-4">
<section className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>{order.customer_name}</h2>
<div className="mt-2 space-y-1">
{order.customer_phone && (
<a href={`tel:${order.customer_phone}`} className="block text-body" style={{ color: "var(--color-accent)" }}>
{order.customer_phone}
</a>
)}
{order.customer_email && (
<a href={`mailto:${order.customer_email}`} className="block text-body" style={{ color: "var(--color-accent)" }}>
{order.customer_email}
</a>
)}
</div>
<div className="mt-3">
<StatusPill status={order.status as StatusKind} />
</div>
</section>
<section className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
<h3 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
ITEMS
</h3>
<ul className="space-y-3">
{order.items.map((item) => (
<li key={item.id} className="flex items-center gap-3">
{item.image_url && (
<img src={item.image_url} alt="" className="w-14 h-14 rounded-xl object-cover shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-h2" style={{ fontWeight: 700 }}>{item.name}</div>
<div className="text-meta" style={{ color: "var(--color-text-muted)" }}>
{item.quantity} × ${(item.price_cents / 100).toFixed(2)}
</div>
</div>
<div className="text-body font-semibold">
${((item.price_cents * item.quantity) / 100).toFixed(2)}
</div>
</li>
))}
</ul>
<div className="mt-4 pt-3 border-t flex items-center justify-between" style={{ borderColor: "var(--color-surface-3)" }}>
<span className="text-label font-semibold" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>TOTAL</span>
<span className="text-h1 font-display" style={{ fontWeight: 600 }}>${(order.total_cents / 100).toFixed(2)}</span>
</div>
</section>
{order.notes && (
<section className="rounded-2xl p-4 italic" style={{ backgroundColor: "var(--color-surface-2)" }}>
<p className="text-body">"{order.notes}"</p>
</section>
)}
</div>
<StickyActionBar>
<OrderActionButtons orderId={order.id} status={order.status as StatusKind} />
</StickyActionBar>
</main>
);
}
```
- [ ] **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<string | null>(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 (
<button
type="button"
onClick={() => handleAction("markOrderReady")}
disabled={pending}
className="w-full rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--color-accent)", color: "white", minHeight: "56px", letterSpacing: "0.02em" }}
>
{pending ? "Marking…" : "Mark Ready"}
</button>
);
}
if (status === "ready") {
return (
<button
type="button"
onClick={() => handleAction("markOrderPickedUp")}
disabled={pending}
className="w-full rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--color-accent)", color: "white", minHeight: "56px", letterSpacing: "0.02em" }}
>
{pending ? "Marking…" : "Mark Picked Up"}
</button>
);
}
if (conflict) {
return (
<div className="text-body" style={{ color: "var(--color-danger)" }}>{conflict}</div>
);
}
return (
<a
href={`/admin/orders/${orderId}/receipt`}
className="block w-full text-center rounded-xl text-label font-semibold active:opacity-80 transition-opacity"
style={{ border: "1px solid var(--color-surface-3)", minHeight: "56px", lineHeight: "56px", letterSpacing: "0.02em" }}
>
View Receipt
</a>
);
}
export default OrderActionButtons;
```
- [ ] **Step 4: Verify**
Run: `npm run dev`, navigate to `/admin/v2/orders/<some-id>`.
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<string, typeof stops> = { 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 (
<main>
<PageHeader title="Stops" subtitle={formatDateHeading(date)} actions={<StopsDatePicker value={date} />} />
<EmptyState title="No stops scheduled" description="Add a stop to start routing." icon="🚚" />
</main>
);
}
const groups = groupByTimeOfDay(stops);
return (
<main className="pb-24">
<PageHeader title="Stops" subtitle={formatDateHeading(date)} actions={<StopsDatePicker value={date} />} />
{Object.entries(groups).map(([time, groupStops]) => groupStops.length > 0 && (
<section key={time}>
<h2 className="sticky top-0 px-4 py-2 text-label font-semibold" style={{ backgroundColor: "var(--color-surface)", color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
{time.toUpperCase()}
</h2>
<CardList ariaLabel={`${time} stops`}>
{groupStops.map((stop) => (
<CardListItem key={stop.id} href={stop.maps_url ?? "#"}>
<div className="flex items-center gap-3">
<div className="text-h1 font-display shrink-0" style={{ fontWeight: 600, minWidth: "72px" }}>
{formatTime(stop.scheduled_at)}
</div>
<div className="flex-1 min-w-0">
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{stop.address}</div>
<div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>
{stop.customer_count} customer{stop.customer_count === 1 ? "" : "s"}
</div>
</div>
<StatusPill status={stop.status as StatusKind} />
</div>
</CardListItem>
))}
</CardList>
</section>
))}
</main>
);
}
```
- [ ] **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 (
<label
className="rounded-xl flex items-center gap-2"
style={{
minHeight: "56px",
padding: "0 16px",
backgroundColor: "var(--color-surface-2)",
fontWeight: 600,
letterSpacing: "0.02em",
}}
>
<span className="text-label" style={{ color: "var(--color-text-muted)" }}>Date</span>
<input
type="date"
value={toInputValue(value)}
onChange={(e) => {
const newDate = e.target.value;
router.push(`/admin/v2/stops?date=${newDate}`);
}}
style={{
background: "transparent",
border: 0,
outline: "none",
fontSize: "15px",
fontWeight: 600,
color: "var(--color-text)",
}}
/>
</label>
);
}
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 (
<main>
<PageHeader title="Products" />
<ProductsFilterChips active={params.filter ?? "all"} />
<EmptyState title="No products" description="Add your first product to start selling." icon="🥬" />
</main>
);
}
return (
<main className="pb-24">
<PageHeader title="Products" subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`} />
<ProductsFilterChips active={params.filter ?? "all"} />
<CardList ariaLabel="Products">
{products.map((product) => {
const stock = stockStatus(product.stock);
return (
<CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
<div className="flex items-start gap-3">
{product.image_url && (
<img src={product.image_url} alt="" className="w-16 h-16 rounded-2xl object-cover shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{product.name}</div>
<StockAdjustButton productId={product.id} currentStock={product.stock} />
</div>
<div className="text-h1 font-display mt-1" style={{ fontWeight: 600 }}>${(product.price_cents / 100).toFixed(2)}</div>
<div className="mt-2">
<StatusPill status={stock.kind} label={stock.label} />
</div>
</div>
</div>
</CardListItem>
);
})}
</CardList>
</main>
);
}
```
- [ ] **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 (
<div className="px-4 pb-3 flex gap-2 overflow-x-auto" role="tablist" aria-label="Filter products">
{FILTERS.map((f) => {
const isActive = active === f.value;
return (
<button
key={f.value}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => apply(f.value)}
className="rounded-full text-label font-semibold whitespace-nowrap"
style={{
minHeight: "40px",
padding: "0 16px",
backgroundColor: isActive ? "var(--color-accent)" : "var(--color-surface-2)",
color: isActive ? "white" : "var(--color-text)",
letterSpacing: "0.02em",
}}
>
{f.label}
</button>
);
})}
</div>
);
}
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<ReturnType<typeof setTimeout> | 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 (
<>
<button
type="button"
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
aria-label={`Adjust stock for product ${productId}`}
className="rounded-full"
style={{
minWidth: "40px",
minHeight: "40px",
backgroundColor: "var(--color-surface-3)",
color: "var(--color-text)",
}}
>
±
</button>
{open && (
<div role="dialog" aria-label="Adjust stock" className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
<div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
<div className="mt-6 flex items-center justify-center gap-6">
<button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}></button>
<div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
</div>
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
</div>
</div>
)}
</>
);
}
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 (
<main>
<PageHeader title="Today" />
<EmptyState title="No brand selected" description="Select a brand to view its dashboard." icon="🌾" />
</main>
);
}
const [summary, stops, pendingOrders] = await Promise.all([
getDashboardSummary(brandId),
getStopsForBrand(brandId, { date: new Date() }),
getOrdersForBrand(brandId, { status: "placed", limit: 5 }),
]);
return (
<main className="pb-24">
<PageHeader title="Today" subtitle={formatDate(new Date())} />
<div className="px-4 space-y-4">
{summary.pending_fulfillment > 0 && (
<Link
href="/admin/v2/orders?status=placed"
className="block rounded-2xl p-4 active:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--color-warning-soft)", borderLeft: "4px solid var(--color-warning)" }}
>
<div className="text-label font-semibold" style={{ color: "var(--color-warning)", letterSpacing: "0.02em" }}>
NEEDS YOU
</div>
<div className="text-h2 mt-1" style={{ fontWeight: 700 }}>
{summary.pending_fulfillment} order{summary.pending_fulfillment === 1 ? "" : "s"} waiting
</div>
<div className="text-meta mt-1" style={{ color: "var(--color-text-muted)" }}>View all </div>
</Link>
)}
<section className="grid grid-cols-2 gap-3">
<StatCard label="Orders" value={String(summary.orders_today)} />
<StatCard label="Revenue" value={`$${(summary.revenue_today / 100).toFixed(2)}`} />
<StatCard label="Pending" value={String(summary.pending_fulfillment)} accent={summary.pending_fulfillment > 0} />
<StatCard label="Stops" value={String(summary.stops_today)} />
</section>
{stops.length > 0 && (
<section>
<h2 className="text-label font-semibold mb-3" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
TODAY'S STOPS
</h2>
<ul className="space-y-2">
{stops.slice(0, 5).map((stop) => (
<li key={stop.id} className="rounded-2xl p-4" style={{ backgroundColor: "var(--color-surface-2)" }}>
<div className="text-meta" style={{ color: "var(--color-text-muted)" }}>
{new Date(stop.scheduled_at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
</div>
<div className="text-h2 mt-1" style={{ fontWeight: 700 }}>{stop.address}</div>
</li>
))}
</ul>
</section>
)}
</div>
</main>
);
}
function StatCard({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
<div className="rounded-2xl p-4" style={{ backgroundColor: accent ? "var(--color-warning-soft)" : "var(--color-surface-2)" }}>
<div className="text-label font-semibold" style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}>
{label.toUpperCase()}
</div>
<div className="text-h1 font-display mt-1" style={{ fontWeight: 600, color: accent ? "var(--color-warning)" : "var(--color-text)" }}>
{value}
</div>
</div>
);
}
```
> **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 `<section>` 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<DashboardSummary> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Unauthorized");
const result = await pool.query<DashboardSummary>(
"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