837f7f83cb
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).
4066 lines
133 KiB
Markdown
4066 lines
133 KiB
Markdown
# 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.48–10.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.4–3.6, 4.2). The OfflineBanner is unit-tested indirectly via the queue + sync tests (Tasks 1.5, 1.6). The visual regression approach is more meaningful for purely presentational components and avoids duplication. If dedicated unit tests are later desired, they're additive — they don't block this plan.
|
||
|
||
PR 1 is complete. Proceed to PR 2.
|
||
|
||
---
|
||
|
||
## PR 2 — PWA manifest + service worker + icons + offline page
|
||
|
||
This PR makes the app installable. The PWA infrastructure (icons, screenshots, manifest, SW, offline page, meta tags) is added but no existing routes are modified.
|
||
|
||
### Task 2.1: Generate the icon set
|
||
|
||
**Files:**
|
||
- Create: `public/icons/icon-72x72.png` ... `icon-512x512.png` (8 files)
|
||
- Create: `public/icons/icon-maskable-512x512.png`
|
||
- Create: `public/icons/badge-72x72.png`
|
||
- Create: `public/icons/orders-shortcut.png`, `products-shortcut.png`, `stops-shortcut.png`
|
||
|
||
- [ ] **Step 1: Use a Node script to generate all icons from the SVG favicon**
|
||
|
||
Create `scripts/generate-pwa-icons.js`:
|
||
|
||
```javascript
|
||
// scripts/generate-pwa-icons.js
|
||
// Generates the full PWA icon set from public/favicon.svg
|
||
// Run: node scripts/generate-pwa-icons.js
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const sharp = require("sharp");
|
||
|
||
const SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
|
||
const OUT_DIR = path.join(__dirname, "..", "public", "icons");
|
||
|
||
const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg");
|
||
const svg = fs.readFileSync(SVG_PATH);
|
||
|
||
async function main() {
|
||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||
for (const size of SIZES) {
|
||
await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`));
|
||
console.log(`✓ icon-${size}x${size}.png`);
|
||
}
|
||
// Maskable: same icon, with 20% safe-area padding baked in
|
||
await sharp(svg).resize(410, 410).extend({
|
||
top: 51, bottom: 51, left: 51, right: 51,
|
||
background: { r: 22, g: 77, b: 46, alpha: 1 },
|
||
}).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png"));
|
||
console.log("✓ icon-maskable-512x512.png");
|
||
// Badge
|
||
await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png"));
|
||
console.log("✓ badge-72x72.png");
|
||
// Shortcuts (same icon, 96x96)
|
||
for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) {
|
||
await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`));
|
||
console.log(`✓ ${name}.png`);
|
||
}
|
||
}
|
||
|
||
main().catch((err) => { console.error(err); process.exit(1); });
|
||
```
|
||
|
||
- [ ] **Step 2: Install `sharp` and run the script**
|
||
|
||
Run: `npm install --save-dev sharp`
|
||
Run: `node scripts/generate-pwa-icons.js`
|
||
Expected: 13 files generated under `public/icons/`.
|
||
|
||
- [ ] **Step 3: Verify file sizes**
|
||
|
||
Run: `ls -la public/icons/`
|
||
Expected: 13 PNGs, each 1-50KB.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add public/icons/ scripts/generate-pwa-icons.js package.json package-lock.json
|
||
git commit -m "feat(pwa): generate 13-icon set from favicon (sizes 72-512 + maskable + badge + 3 shortcuts)"
|
||
```
|
||
|
||
### Task 2.2: Generate the screenshots
|
||
|
||
**Files:**
|
||
- Create: `public/screenshots/dashboard.png` (1280x720, desktop)
|
||
- Create: `public/screenshots/mobile-storefront.png` (390x844, mobile)
|
||
|
||
- [ ] **Step 1: Create screenshots manually**
|
||
|
||
For now, generate placeholder screenshots using `sharp` from solid backgrounds with the brand color:
|
||
|
||
Create `scripts/generate-pwa-screenshots.js`:
|
||
|
||
```javascript
|
||
// scripts/generate-pwa-screenshots.js
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const sharp = require("sharp");
|
||
|
||
const OUT_DIR = path.join(__dirname, "..", "public", "screenshots");
|
||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||
|
||
async function main() {
|
||
// Desktop dashboard placeholder
|
||
await sharp({
|
||
create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } }
|
||
})
|
||
.composite([{
|
||
input: Buffer.from(`<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
|