docs: admin mobile PWA + HIG design spec
This commit is contained in:
@@ -0,0 +1,516 @@
|
|||||||
|
# Admin Mobile PWA — Design Spec
|
||||||
|
|
||||||
|
- **Date:** 2026-06-17
|
||||||
|
- **Status:** Draft, awaiting user review
|
||||||
|
- **Author:** Grok
|
||||||
|
- **Branch:** `main`
|
||||||
|
- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.
|
||||||
|
|
||||||
|
**Symptoms today:**
|
||||||
|
|
||||||
|
- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
|
||||||
|
- `registerServiceWorker()` is exported but never called
|
||||||
|
- `PWAInstallPrompt` is never mounted in the tree
|
||||||
|
- `apple-touch-icon.png` and `favicon.ico` are referenced but missing
|
||||||
|
- Service worker references `/offline` and `/og-default.jpg` that don't exist
|
||||||
|
- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`)
|
||||||
|
- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
|
||||||
|
- Body text is 16pt; tap targets are 40–44pt; status colors don't all clear WCAG AA on the warm cream background
|
||||||
|
- No offline mutation queue — losing signal mid-fulfillment is a real operational problem
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
|
||||||
|
2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
|
||||||
|
3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
|
||||||
|
4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
|
||||||
|
5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Redesigning marketing pages (landing, pricing, blog). They stay as-is.
|
||||||
|
- Redesigning the public storefronts. They stay as-is.
|
||||||
|
- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
|
||||||
|
- Wholesale portal mobile redesign. Tabled for a future spec.
|
||||||
|
- Real-time collaboration (websockets / live cursors). Out of scope.
|
||||||
|
- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`.
|
||||||
|
|
||||||
|
## Surfaces in scope
|
||||||
|
|
||||||
|
- `/admin` (Dashboard)
|
||||||
|
- `/admin/orders` + `/admin/orders/[id]`
|
||||||
|
- `/admin/stops`
|
||||||
|
- `/admin/products`
|
||||||
|
- The admin shell that wraps all of the above
|
||||||
|
- PWA install, splash, offline page, and SW behavior across the whole app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Aesthetic direction & design system
|
||||||
|
|
||||||
|
### Direction: "HIG-First Field Almanac"
|
||||||
|
|
||||||
|
Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.
|
||||||
|
|
||||||
|
### Type scale (HIG Accessibility-tier)
|
||||||
|
|
||||||
|
| Token | Size / Weight | Use |
|
||||||
|
| ---------------- | ----------------------------------- | -------------------------------------- |
|
||||||
|
| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) |
|
||||||
|
| `text-h1` | 24pt / Fraunces 600 | Section headers |
|
||||||
|
| `text-h2` | 19pt / Manrope 700 | Card titles, list primary |
|
||||||
|
| `text-body` | 18pt / Manrope 500 | All body text |
|
||||||
|
| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills|
|
||||||
|
| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers |
|
||||||
|
| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text |
|
||||||
|
|
||||||
|
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
|
||||||
|
|
||||||
|
### Color tokens (AAA contrast on `surface`)
|
||||||
|
|
||||||
|
| Token | Value | Use | Contrast on `#faf8f5` |
|
||||||
|
| ------------------ | --------- | --------------------------------------- | --------------------- |
|
||||||
|
| `bg` | `#ffffff` | App background | — |
|
||||||
|
| `surface` | `#faf8f5` | Page background (warm cream) | — |
|
||||||
|
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
|
||||||
|
| `surface-3` | `#e8e8ed` | Hover / pressed | — |
|
||||||
|
| `text` | `#1d1d1f` | Primary text | 14.6 : 1 |
|
||||||
|
| `text-muted` | `#424245` | Secondary text | 9.4 : 1 |
|
||||||
|
| `text-faint` | `#6e6e73` | Tertiary / meta | 5.3 : 1 (AA only) |
|
||||||
|
| `accent` | `#166534` | Primary actions, brand | 8.2 : 1 |
|
||||||
|
| `accent-2` | `#15803d` | Hover state | 7.4 : 1 |
|
||||||
|
| `danger` | `#b91c1c` | Errors, destructive | 7.1 : 1 |
|
||||||
|
| `warning` | `#854d0e` | Warnings, low stock | 7.6 : 1 |
|
||||||
|
| `success` | `#166534` | Success, completed | 8.2 : 1 |
|
||||||
|
| `info` | `#1e40af` | Informational | 9.0 : 1 |
|
||||||
|
|
||||||
|
`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.
|
||||||
|
|
||||||
|
### Spacing (8pt grid)
|
||||||
|
|
||||||
|
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`
|
||||||
|
|
||||||
|
### Touch targets (HIG Accessibility + gloves)
|
||||||
|
|
||||||
|
- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves)
|
||||||
|
- Tab bar items: 60pt tall, icon 28pt + label 12pt
|
||||||
|
- List row primary action: full-row 72pt tap zone
|
||||||
|
- Bottom sheet drag handle: 56pt wide × 5pt tall
|
||||||
|
- FAB: 64pt diameter
|
||||||
|
- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
- All motion respects `prefers-reduced-motion`
|
||||||
|
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||||
|
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||||
|
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||||
|
- Pull-to-refresh: custom indicator showing last-synced time in mono
|
||||||
|
- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture & data flow
|
||||||
|
|
||||||
|
### Mobile admin shell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Page content │ ← scrollable, max 720pt content width
|
||||||
|
│ (card-stacked) │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed
|
||||||
|
│ [More] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More**
|
||||||
|
- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
|
||||||
|
- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
|
||||||
|
- **No desktop sidebar on mobile** — `AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged.
|
||||||
|
- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`.
|
||||||
|
|
||||||
|
### New shared components
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint |
|
||||||
|
| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` |
|
||||||
|
| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `<dialog>` + View Transitions |
|
||||||
|
| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row |
|
||||||
|
| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) |
|
||||||
|
| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages |
|
||||||
|
| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile |
|
||||||
|
| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens |
|
||||||
|
| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count |
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
| Page | Server source | Mobile client behavior |
|
||||||
|
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
|
||||||
|
| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone |
|
||||||
|
| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time |
|
||||||
|
| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar |
|
||||||
|
| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action |
|
||||||
|
| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock |
|
||||||
|
|
||||||
|
### Offline mutation queue
|
||||||
|
|
||||||
|
- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId`
|
||||||
|
- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff
|
||||||
|
- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online
|
||||||
|
- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar`
|
||||||
|
- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count
|
||||||
|
- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges.
|
||||||
|
- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item.
|
||||||
|
- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id.
|
||||||
|
|
||||||
|
### New RPC (for dashboard)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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), 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;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PWA, offline, and production hardening
|
||||||
|
|
||||||
|
### PWA — what's broken and what we're fixing
|
||||||
|
|
||||||
|
| Issue today | Fix |
|
||||||
|
| ------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||||
|
| `public/icons/` doesn't exist | Generate all 8 sizes (72–512) PNG + maskable + badge + 3 shortcut icons |
|
||||||
|
| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) |
|
||||||
|
| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 |
|
||||||
|
| `favicon.ico` missing | Generate from SVG |
|
||||||
|
| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) |
|
||||||
|
| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` |
|
||||||
|
| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) |
|
||||||
|
| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) |
|
||||||
|
| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) |
|
||||||
|
| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` |
|
||||||
|
| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices |
|
||||||
|
|
||||||
|
### Service worker rewrite
|
||||||
|
|
||||||
|
Replace `public/sw.js` with a versioned, two-cache SW:
|
||||||
|
|
||||||
|
- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`.
|
||||||
|
- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background.
|
||||||
|
- **Cache strategies:**
|
||||||
|
- Navigations → network-first, fall back to cache, fall back to `/offline.html`
|
||||||
|
- Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first
|
||||||
|
- API GETs (`/api/*` non-mutation) → stale-while-revalidate
|
||||||
|
- API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue
|
||||||
|
- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast.
|
||||||
|
- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`.
|
||||||
|
|
||||||
|
### Production hardening
|
||||||
|
|
||||||
|
- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*`
|
||||||
|
- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays
|
||||||
|
- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar
|
||||||
|
- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
|
||||||
|
- No layout shift on font load: `next/font` reserves metric-based space
|
||||||
|
|
||||||
|
### Files touched in this section
|
||||||
|
|
||||||
|
- `public/manifest.json` (theme_color + new screenshot paths)
|
||||||
|
- `public/sw.js` (full rewrite)
|
||||||
|
- `public/offline.html` (new)
|
||||||
|
- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files)
|
||||||
|
- `public/screenshots/*.png` (2 new)
|
||||||
|
- `public/apple-touch-icon.png` + variants (3 new)
|
||||||
|
- `public/favicon.ico` (1 new)
|
||||||
|
- `src/app/layout.tsx` (add PWA meta + register SW via Providers)
|
||||||
|
- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount)
|
||||||
|
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`)
|
||||||
|
- `next.config.ts` (cache headers for static assets)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Page-by-page layouts
|
||||||
|
|
||||||
|
All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`.
|
||||||
|
|
||||||
|
### Dashboard (`/admin`)
|
||||||
|
|
||||||
|
- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
|
||||||
|
- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
|
||||||
|
- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
|
||||||
|
- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable
|
||||||
|
- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.
|
||||||
|
|
||||||
|
### Orders list (`/admin/orders`)
|
||||||
|
|
||||||
|
- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order"
|
||||||
|
- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar
|
||||||
|
- **Card list** (one per order):
|
||||||
|
- Top row: status pill (left) · order # in mono · time-ago (right)
|
||||||
|
- Middle: customer name (Manrope 700 19pt) + item count ("4 items")
|
||||||
|
- Bottom: total (Fraunces 22pt) · arrow chevron
|
||||||
|
- Whole row: 72pt tap zone
|
||||||
|
- **Empty state:** "No orders yet" + illustration + "+ New order" CTA
|
||||||
|
- **Pull-to-refresh** at the top
|
||||||
|
|
||||||
|
### Order detail (`/admin/orders/[id]`)
|
||||||
|
|
||||||
|
- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
|
||||||
|
- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
|
||||||
|
- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
|
||||||
|
- **Customer notes card** (if present): quoted, italic
|
||||||
|
- **StickyActionBar** (bottom, above tab bar): primary action changes by status:
|
||||||
|
- Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
|
||||||
|
- Ready → "Mark Picked Up" (forest-800 fill)
|
||||||
|
- Picked up → "View Receipt" (outlined)
|
||||||
|
- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu
|
||||||
|
- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays
|
||||||
|
|
||||||
|
### Stops (`/admin/stops`)
|
||||||
|
|
||||||
|
- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop"
|
||||||
|
- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
|
||||||
|
- **Stop card:**
|
||||||
|
- Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
|
||||||
|
- Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL)
|
||||||
|
- Inline status changer: tap status pill → bottom sheet with status options
|
||||||
|
- Whole row: 88pt to accommodate time + address comfortably
|
||||||
|
- **Empty state:** "No stops scheduled" + "+ New stop"
|
||||||
|
|
||||||
|
### Products (`/admin/products`)
|
||||||
|
|
||||||
|
- **Page header:** "Products" + search input (always visible, prominent) + "+ New product"
|
||||||
|
- **Card list:**
|
||||||
|
- Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
|
||||||
|
- Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
|
||||||
|
- "Adjust stock" via long-press → bottom sheet with +/− stepper
|
||||||
|
- **Filter chips above list:** All · In stock · Low · Out · Hidden
|
||||||
|
- **Empty state:** "No products" + "+ New product"
|
||||||
|
|
||||||
|
### Motion (per page)
|
||||||
|
|
||||||
|
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||||
|
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||||
|
- Pull-to-refresh: custom indicator showing last-synced time
|
||||||
|
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||||
|
- All motion respects `prefers-reduced-motion`
|
||||||
|
|
||||||
|
### Aesthetic summary
|
||||||
|
|
||||||
|
Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing & verification
|
||||||
|
|
||||||
|
### Unit tests (Vitest)
|
||||||
|
|
||||||
|
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
|
||||||
|
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
|
||||||
|
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
|
||||||
|
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG AAA (7:1) on `surface` and `surface-2` backgrounds; fails CI if a new color regresses
|
||||||
|
|
||||||
|
### Component tests (Vitest + Testing Library)
|
||||||
|
|
||||||
|
- `MobileTabBar` — active state, more-sheet trigger, keyboard nav
|
||||||
|
- `StickyActionBar` — pending badge, conflict state
|
||||||
|
- `OfflineBanner` — appears on `offline` event, hidden on `online`
|
||||||
|
- `StatusPill` — every status × every color combination renders the right aria-label
|
||||||
|
|
||||||
|
### E2E (Playwright)
|
||||||
|
|
||||||
|
New spec: `tests/mobile-admin/pwa-install.spec.ts`
|
||||||
|
- Loads `/admin` on iPhone 13 viewport
|
||||||
|
- Asserts manifest link present, manifest is valid JSON, all icons return 200
|
||||||
|
- Asserts `apple-touch-icon` link present
|
||||||
|
- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`)
|
||||||
|
|
||||||
|
New spec: `tests/mobile-admin/orders-list.spec.ts`
|
||||||
|
- Loads `/admin/orders` on iPhone 13 viewport
|
||||||
|
- Asserts no horizontal scroll (page width ≤ viewport)
|
||||||
|
- Asserts every interactive element has touch target ≥ 48pt
|
||||||
|
- Asserts all text passes axe-core
|
||||||
|
|
||||||
|
New spec: `tests/mobile-admin/offline-queue.spec.ts`
|
||||||
|
- Loads `/admin/orders/<id>` on iPhone 13 viewport
|
||||||
|
- Goes offline (Playwright `context.setOffline(true)`)
|
||||||
|
- Clicks "Mark Ready"
|
||||||
|
- Asserts the optimistic UI updates
|
||||||
|
- Asserts the action bar shows "Pending sync" badge
|
||||||
|
- Goes back online
|
||||||
|
- Asserts the badge clears within 5s and the server has the new status
|
||||||
|
|
||||||
|
### Visual regression (Playwright `toHaveScreenshot`)
|
||||||
|
|
||||||
|
- All 4 key page screens at 390×844 (iPhone 13)
|
||||||
|
- All 4 key page screens at 1024×768 (iPad)
|
||||||
|
- MobileTabBar — light + dark
|
||||||
|
- OfflineBanner — visible, hidden
|
||||||
|
|
||||||
|
### Manual QA checklist (added to PR template)
|
||||||
|
|
||||||
|
- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
|
||||||
|
- [ ] Tap every interactive element while wearing work gloves
|
||||||
|
- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
|
||||||
|
- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
|
||||||
|
- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass
|
||||||
|
|
||||||
|
### Lighthouse / PWA gate (CI)
|
||||||
|
|
||||||
|
- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR
|
||||||
|
- PWA category must pass (installable, manifest valid, service worker registered, themed)
|
||||||
|
- Mobile accessibility score must be ≥ 95
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device
|
||||||
|
- `docs/admin-mobile.md` — design rationale + token reference for the team
|
||||||
|
|
||||||
|
### Files touched in this section
|
||||||
|
|
||||||
|
~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File inventory (consolidated)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8)
|
||||||
|
- `public/icons/icon-maskable-512x512.png` (1)
|
||||||
|
- `public/icons/badge-72x72.png` (1)
|
||||||
|
- `public/icons/{orders,products,stops}-shortcut.png` (3)
|
||||||
|
- `public/screenshots/dashboard.png` (1)
|
||||||
|
- `public/screenshots/mobile-storefront.png` (1)
|
||||||
|
- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3)
|
||||||
|
- `public/favicon.ico` (1)
|
||||||
|
- `public/offline.html` (1)
|
||||||
|
- `src/components/admin/AdminShell.tsx`
|
||||||
|
- `src/components/admin/MobileTabBar.tsx`
|
||||||
|
- `src/components/admin/MoreSheet.tsx`
|
||||||
|
- `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`
|
||||||
|
- `src/components/admin/OfflineBanner.tsx`
|
||||||
|
- `src/lib/offline/queue.ts`
|
||||||
|
- `src/lib/offline/sync.ts`
|
||||||
|
- `src/lib/offline/db.ts` (IndexedDB wrapper)
|
||||||
|
- `src/lib/__tests__/design-tokens.test.ts`
|
||||||
|
- `src/lib/offline/queue.test.ts`
|
||||||
|
- `src/lib/offline/sync.test.ts`
|
||||||
|
- `tests/mobile-admin/pwa-install.spec.ts`
|
||||||
|
- `tests/mobile-admin/orders-list.spec.ts`
|
||||||
|
- `tests/mobile-admin/offline-queue.spec.ts`
|
||||||
|
- `.gitea/workflows/pwa-audit.yml`
|
||||||
|
- `docs/pwa-testing.md`
|
||||||
|
- `docs/admin-mobile.md`
|
||||||
|
- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard)
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `public/manifest.json` (theme_color, screenshot paths)
|
||||||
|
- `public/sw.js` (full rewrite)
|
||||||
|
- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor)
|
||||||
|
- `src/components/Providers.tsx` (call `registerServiceWorker()`)
|
||||||
|
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`)
|
||||||
|
- `src/app/admin/page.tsx` (Dashboard card layout)
|
||||||
|
- `src/app/admin/orders/page.tsx` (CardList)
|
||||||
|
- `src/app/admin/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration)
|
||||||
|
- `src/app/admin/stops/page.tsx` (time-grouped card list)
|
||||||
|
- `src/app/admin/products/page.tsx` (image-forward card list)
|
||||||
|
- `src/app/globals.css` (new type scale, color tokens, spacing)
|
||||||
|
- `src/styles/admin-design-system.css` (admin-specific tokens)
|
||||||
|
- `next.config.ts` (cache headers for static assets)
|
||||||
|
- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport)
|
||||||
|
- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging)
|
||||||
|
- `src/app/page.tsx` (favicon ref fix)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions (deferred)
|
||||||
|
|
||||||
|
- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
|
||||||
|
- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped.
|
||||||
|
- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
|
||||||
|
- **Image upload from camera** — products/stops could use this; separate spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
|
||||||
|
2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
|
||||||
|
3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
|
||||||
|
4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout plan
|
||||||
|
|
||||||
|
1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
|
||||||
|
2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass.
|
||||||
|
3. **PR 3 — Orders (list + detail)** — first page on the new design language. Validates the components in real use.
|
||||||
|
4. **PR 4 — Stops**
|
||||||
|
5. **PR 5 — Products**
|
||||||
|
6. **PR 6 — Dashboard** (depends on the new `get_dashboard_summary` RPC migration)
|
||||||
|
|
||||||
|
Each PR is independently shippable behind a feature flag (`ADMIN_MOBILE_V2`) so we can dogfood it internally before cutting over. The desktop layout is never affected until the flag is on for everyone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- [ ] All 4 key pages ship the mobile-first treatment
|
||||||
|
- [ ] PWA installs on iOS Safari and Android Chrome
|
||||||
|
- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation
|
||||||
|
- [ ] Offline mutation queue replays actions on reconnect with no data loss
|
||||||
|
- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile
|
||||||
|
- [ ] All text passes WCAG AAA (7:1) against its background
|
||||||
|
- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
|
||||||
|
- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
|
||||||
|
- [ ] `prefers-reduced-motion` honored
|
||||||
|
- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI
|
||||||
|
- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports
|
||||||
|
- [ ] Real-device install + offline testing documented and signed off
|
||||||
Reference in New Issue
Block a user