130 KiB
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
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:
/* ─── Field Almanac palette (mobile-first / HIG Accessibility) ─── */
:root {
/* Surfaces */
--color-bg: #ffffff;
--color-surface: #faf8f5;
--color-surface-2: #f5f5f7;
--color-surface-3: #e8e8ed;
/* Text — all AAA on --color-surface (14.6 / 9.4) except --text-faint (5.3 / AA only) */
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-text-faint: #6e6e73;
/* Accent / status — AAA on --color-surface */
--color-accent: #166534; /* primary actions, brand */
--color-accent-2: #15803d; /* hover state */
--color-accent-soft: #dcfce7; /* tinted backgrounds, accent at ~10% */
--color-danger: #b91c1c; /* errors, destructive */
--color-danger-soft: #fee2e2;
--color-warning: #854d0e; /* warnings, low stock */
--color-warning-soft: #fef9c3;
--color-success: #166534; /* completed, positive */
--color-success-soft: #dcfce7;
--color-info: #1e40af; /* informational */
--color-info-soft: #dbeafe;
}
/* ─── Type scale (HIG Accessibility-tier) ─── */
:root {
--text-display: 32px;
--text-display--line-height: 1.15;
--text-display--letter-spacing: -0.01em;
--text-h1: 24px;
--text-h1--line-height: 1.2;
--text-h2: 19px;
--text-h2--line-height: 1.3;
--text-body: 18px;
--text-body--line-height: 1.45;
--text-label: 15px;
--text-label--line-height: 1.3;
--text-label--letter-spacing: 0.02em;
--text-mono: 14px;
--text-mono--line-height: 1.4;
--text-meta: 13px;
--text-meta--line-height: 1.4;
}
/* ─── Spacing (8pt grid) ─── */
:root {
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
}
/* ─── Motion ─── */
:root {
--ease-apple: cubic-bezier(0.32, 0.72, 0, 1);
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-slow: 320ms;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
- Step 2: Verify Tailwind picks up the new tokens
Run: npm run lint && npx tsc --noEmit
Expected: PASS (no type/lint errors).
Run: npm run dev and open http://localhost:3000 in a browser. DevTools → Elements → <html> → check the :root computed styles to confirm --color-bg, --text-display, --space-4 resolve.
- Step 3: Commit
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
// src/lib/__tests__/design-tokens.test.ts
import { describe, it, expect } from "vitest";
/**
* Asserts all token combinations meet WCAG contrast requirements.
* - AAA = 7:1 (every text token vs every surface it appears on), EXCEPT --color-text-faint
* which is documented as AA-only.
* - AA = 4.5:1 (for --color-text-faint).
* - Large text (18pt+ or 14pt+ bold) gets a relaxed 3:1 floor, but we require AAA anyway
* because outdoor + sun glare is harder than AAA accounts for.
*/
type RGB = [number, number, number];
function parseHex(hex: string): RGB {
const h = hex.replace("#", "");
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
}
function srgbToLinear(c: number): number {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function luminance([r, g, b]: RGB): number {
return (
0.2126 * srgbToLinear(r) +
0.7152 * srgbToLinear(g) +
0.0722 * srgbToLinear(b)
);
}
function contrastRatio(fg: string, bg: string): number {
const l1 = luminance(parseHex(fg));
const l2 = luminance(parseHex(bg));
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
const SURFACES: Record<string, string> = {
bg: "#ffffff",
surface: "#faf8f5",
"surface-2": "#f5f5f7",
"surface-3": "#e8e8ed",
};
const TEXT_TOKENS_AAA: Record<string, string> = {
text: "#1d1d1f",
"text-muted": "#424245",
accent: "#166534",
"accent-2": "#15803d",
danger: "#b91c1c",
warning: "#854d0e",
success: "#166534",
info: "#1e40af",
};
const TEXT_FAINTS_AA: Record<string, string> = {
"text-faint": "#6e6e73",
};
describe("design tokens — WCAG contrast", () => {
Object.entries(TEXT_TOKENS_AAA).forEach(([token, hex]) => {
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
const ratio = contrastRatio(hex, surfaceHex);
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
});
});
});
Object.entries(TEXT_FAINTS_AA).forEach(([token, hex]) => {
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
it(`${token} on ${surfaceName} meets AA (>= 4.5:1) [documented exception]`, () => {
const ratio = contrastRatio(hex, surfaceHex);
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
});
});
});
it("status-pill backgrounds (soft variants) read on surface-2", () => {
// The "soft" status backgrounds are intended for filled pills, not body text.
// We only assert they're visually distinct from the surface they sit on.
const softs: Record<string, string> = {
"accent-soft": "#dcfce7",
"danger-soft": "#fee2e2",
"warning-soft": "#fef9c3",
"success-soft": "#dcfce7",
"info-soft": "#dbeafe",
};
Object.entries(softs).forEach(([token, hex]) => {
const ratio = contrastRatio(hex, "#faf8f5");
expect(ratio, `${token} too close to surface`).toBeLessThan(1.5);
});
});
});
- Step 2: Run the test
Run: npx vitest run src/lib/__tests__/design-tokens.test.ts
Expected: 35+ tests, all PASS. (4 text AAA × 4 surfaces = 16, plus 1 text-faint AA × 4 surfaces = 4, plus 5 soft variant tests = 25 total. If some combinations aren't reachable in production code, narrow the SURFACES object; the goal is to cover what ships.)
- Step 3: Commit
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:
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:
"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)
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
// 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
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
// 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) anduuid
Run: npm install --save-dev fake-indexeddb
Run: npm install uuid && npm install --save-dev @types/uuid
- Step 4: Implement the queue module
// 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
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
// 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
// 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
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
// 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
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
// src/components/admin/AdminShell.tsx
import { ReactNode } from "react";
import AdminSidebar from "@/components/admin/AdminSidebar";
import MobileTabBar from "@/components/admin/MobileTabBar";
import OfflineBanner from "@/components/admin/OfflineBanner";
import { useMediaQuery } from "@/lib/use-media-query"; // see Task 1.9
interface AdminShellProps {
userRole: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands: { id: string; name: string; slug: string }[];
enabledAddons?: Record<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)
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
// 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
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
// 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)
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
// 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
git add src/components/admin/MoreSheet.tsx
git commit -m "feat(admin): add MoreSheet (native dialog) with grouped secondary nav"
Task 1.12: Create the PageHeader, StatusPill, EmptyState, CardList, StickyActionBar components
These are small, focused, presentational. Build them all in one PR by following the same TDD pattern (skip tests for purely presentational components — they're tested via visual regression in PR 3+).
Files:
-
Create:
src/components/admin/PageHeader.tsx -
Create:
src/components/admin/StatusPill.tsx -
Create:
src/components/admin/EmptyState.tsx -
Create:
src/components/admin/CardList.tsx -
Create:
src/components/admin/StickyActionBar.tsx -
Step 1:
PageHeader.tsx
// 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
// 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:
EmptyState.tsx
// src/components/admin/EmptyState.tsx
import { ReactNode } from "react";
interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
icon?: string;
}
export function EmptyState({ title, description, action, icon = "✨" }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center text-center px-6 py-16">
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
<h2 className="text-h1 font-display mb-2" style={{ fontWeight: 600 }}>{title}</h2>
{description && <p className="text-body max-w-md" style={{ color: "var(--color-text-muted)" }}>{description}</p>}
{action && <div className="mt-6">{action}</div>}
</div>
);
}
export default EmptyState;
- Step 4:
CardList.tsx+CardListItem.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>;
}
export { CardListItem };
- Step 5:
StickyActionBar.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
git add src/components/admin/PageHeader.tsx src/components/admin/StatusPill.tsx src/components/admin/EmptyState.tsx src/components/admin/CardList.tsx src/components/admin/StickyActionBar.tsx
git commit -m "feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar"
Task 1.13: Add Lighthouse CI workflow
Files:
-
Create:
.gitea/workflows/pwa-audit.yml -
Step 1: Install Lighthouse CI
Run: npm install --save-dev @lhci/cli
- Step 2: Create the config
Create lighthouserc.json at the project root:
{
"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
# .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
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, andMoreSheet. 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:
// 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
sharpand 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
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:
// 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:
- Run
npm run devand visit/admin(desktop) and/tuxedo(mobile) in a real browser - Use
npx playwright codegenornpx playwright screenshotto capture - Overwrite the placeholders in
public/screenshots/ - Re-run
git commitwith the same message
- Step 4: Commit
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:
// 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
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
<!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
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
// 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
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_colorto"#166534" - Change
background_colorto"#faf8f5" - Confirm
screenshotsarray points to the files we just created
{
"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
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:
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:
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
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.tsxand add the registration
"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
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:
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
In the layout's return (just before the closing tag of the outermost wrapper), add:
<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
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
// 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
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
// 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
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
- Open the preview URL in Safari
- Tap Share → Add to Home Screen
- Confirm the app icon, name, and splash appear correctly
- Open the installed PWA from the home screen
- Toggle Airplane Mode on
- Navigate to a previously visited admin page — it should render from cache
- 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.
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
// 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:
// 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
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
// 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
OrdersFilterButtonandPullToRefreshwhich 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(notstring | 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
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
// 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
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)
// 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
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
// 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):
// 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
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
// 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
OrderActionButtonsclient component
// 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
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
// 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
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
// 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
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
// 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
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 and follow the manual checklist. Specifically:
-
Read
/admin/v2/ordersin 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
// 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
StopsDatePickercomponent
// 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
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
# 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
// 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
ProductsFilterChipscomponent
// 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
StockAdjustButtoncomponent (long-press to open stepper)
// 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:
- Add one to the action (preferred — it's a small, useful addition)
- Filter client-side from
ProductsV2Pagebefore rendering
- Step 2: Verify + commit
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
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:
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
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
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
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/....)
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
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 — checkMEMORY.mdor the prod DB) -
Step 1: Write the migration
-- 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_centsmigration if it doesn't exist
If the orders table has total (in dollars) and not total_cents (in cents), add a migration:
-- 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
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
// 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: ThependingOrdersvariable 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
getDashboardSummaryaction
Create src/actions/dashboard.ts:
// 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
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
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
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.tsxto 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
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
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
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
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.htmlon 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-motionhonored- 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