Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 661c6e8a86 | |||
| 1fa8a78f82 | |||
| afd96b93e1 | |||
| d913ca194e | |||
| f4d5a56181 | |||
| 139445879e | |||
| 818bd4d47b | |||
| 8a912b1e62 | |||
| f7a02fe127 | |||
| 4c428fd9da | |||
| c67df118e2 | |||
| d9a45b0b33 | |||
| cf451f0580 | |||
| 493ca4047f | |||
| cbd6eda640 | |||
| 37998aad46 | |||
| 1577f6363b | |||
| 9b9643e2c7 | |||
| d629cfe3d5 | |||
| 827b9ef318 | |||
| 6fc641a8d4 | |||
| dd545c00d6 | |||
| 487fafc828 | |||
| 5560727cd8 | |||
| 804940ae78 | |||
| 3ac167799b | |||
| 987ddcc25d | |||
| 506b062917 | |||
| 9256d7ac38 | |||
| 4e059e6def | |||
| 3f2c99fbb2 | |||
| 837f7f83cb | |||
| 532511e1b5 | |||
| 6d2c90ae6b | |||
| ef565fbfb1 | |||
| 895defa453 | |||
| 9edbfc2e1b | |||
| 6075d22a84 | |||
| cc5c0e49be | |||
| d50ec4deda | |||
| d3b8e4f7cd | |||
| 6a9807a3be | |||
| 182ccf2c72 | |||
| 6ffd07c54e | |||
| 417379d1af | |||
| ead638d9ae | |||
| f00b327477 | |||
| 6545c2e0d4 | |||
| ddf82a0c66 |
@@ -0,0 +1,34 @@
|
|||||||
|
name: PWA + Mobile A11y Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Start server
|
||||||
|
run: npm run start &
|
||||||
|
env:
|
||||||
|
PORT: 3000
|
||||||
|
|
||||||
|
- name: Wait for server
|
||||||
|
run: sleep 10
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
run: npx lhci autorun
|
||||||
@@ -48,3 +48,4 @@ playwright-report/
|
|||||||
.env*
|
.env*
|
||||||
public/videos/tuxedo-hero.mp4
|
public/videos/tuxedo-hero.mp4
|
||||||
.neon
|
.neon
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- 0091_dashboard_summary_rpc.sql
|
||||||
|
--
|
||||||
|
-- Dashboard summary RPC for the mobile-first admin v2 dashboard
|
||||||
|
-- (`/admin/v2`).
|
||||||
|
--
|
||||||
|
-- Returns a single JSONB document that powers the four stat cards +
|
||||||
|
-- the "Today's stops" timeline + the 7-day mini-chart:
|
||||||
|
--
|
||||||
|
-- {
|
||||||
|
-- "orders_today": <int>, -- count of orders placed today
|
||||||
|
-- "revenue_today": <int cents>, -- sum of total_cents (excl. canceled)
|
||||||
|
-- "pending_fulfillment": <int>, -- count of orders awaiting pickup/ship
|
||||||
|
-- "stops_today": <int>, -- count of stops scheduled for today
|
||||||
|
-- "orders_last_7_days": [{date, count}] -- 7-element array, oldest → today
|
||||||
|
-- }
|
||||||
|
--
|
||||||
|
-- Schema notes (vs. the plan's draft):
|
||||||
|
-- * `orders` uses `placed_at` (TIMESTAMPTZ) — not `created_at`
|
||||||
|
-- * `orders.status` is the legacy enum: 'pending' | 'confirmed' |
|
||||||
|
-- 'fulfilled' | 'canceled' (not the v2 vocabulary 'placed'/'ready')
|
||||||
|
-- * `orders.total_cents` is INTEGER (NOT NULL DEFAULT 0 in 0001) — no
|
||||||
|
-- column-add step is required
|
||||||
|
-- * `stops` has a `date DATE` column (not `scheduled_at`); the legacy
|
||||||
|
-- stops page (and v2 stops page) both key off `stops.date`
|
||||||
|
--
|
||||||
|
-- SECURITY DEFINER so the v2 server action can call it without depending
|
||||||
|
-- on RLS; brand scoping is enforced inside the function via the
|
||||||
|
-- `p_brand_id` parameter (NULL → platform_admin "all brands" scope).
|
||||||
|
-- Re-runs are safe — this is `CREATE OR REPLACE FUNCTION`.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
|
||||||
|
RETURNS JSONB
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
result JSONB;
|
||||||
|
brand_filter TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- The brand filter is the same text fragment for every SELECT in this
|
||||||
|
-- function. NULL = "all brands" (platform_admin scope); non-NULL =
|
||||||
|
-- scope to a single brand.
|
||||||
|
IF p_brand_id IS NULL THEN
|
||||||
|
brand_filter := '';
|
||||||
|
ELSE
|
||||||
|
brand_filter := format(' AND brand_id = %L', p_brand_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
EXECUTE format(
|
||||||
|
$q$
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'orders_today', (
|
||||||
|
SELECT COUNT(*) FROM orders
|
||||||
|
WHERE placed_at::date = CURRENT_DATE %s
|
||||||
|
),
|
||||||
|
'revenue_today', (
|
||||||
|
SELECT COALESCE(SUM(total_cents), 0)::bigint FROM orders
|
||||||
|
WHERE placed_at::date = CURRENT_DATE
|
||||||
|
AND status <> 'canceled' %s
|
||||||
|
),
|
||||||
|
'pending_fulfillment', (
|
||||||
|
SELECT COUNT(*) FROM orders
|
||||||
|
WHERE status IN ('pending', 'confirmed') %s
|
||||||
|
),
|
||||||
|
'stops_today', (
|
||||||
|
SELECT COUNT(*) FROM stops
|
||||||
|
WHERE "date" = CURRENT_DATE %s
|
||||||
|
),
|
||||||
|
'orders_last_7_days', (
|
||||||
|
SELECT COALESCE(jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)) ORDER BY d), '[]'::jsonb)
|
||||||
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT placed_at::date AS day, COUNT(*) AS cnt
|
||||||
|
FROM orders
|
||||||
|
WHERE placed_at >= CURRENT_DATE - INTERVAL '7 days' %s
|
||||||
|
GROUP BY 1
|
||||||
|
) o ON o.day = d::date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
$q$,
|
||||||
|
brand_filter, brand_filter, brand_filter, brand_filter, brand_filter
|
||||||
|
) INTO result;
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION get_dashboard_summary(UUID) IS
|
||||||
|
'Returns a single JSONB document with the four dashboard stat counts + 7-day order history. p_brand_id = NULL means platform_admin scope (all brands).';
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
# 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 where text appears)
|
||||||
|
|
||||||
|
| Token | Value | Use | Contrast on `bg` / `surface` |
|
||||||
|
| ------------- | --------- | --------------------------------------- | ---------------------------- |
|
||||||
|
| `bg` | `#ffffff` | App background | — |
|
||||||
|
| `surface` | `#faf8f5` | Page background (warm cream) | — |
|
||||||
|
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
|
||||||
|
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
|
||||||
|
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
|
||||||
|
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
|
||||||
|
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
|
||||||
|
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
|
||||||
|
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
|
||||||
|
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
|
||||||
|
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
|
||||||
|
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
|
||||||
|
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.48–10.36) |
|
||||||
|
|
||||||
|
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
|
||||||
|
|
||||||
|
`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 contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); 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
|
||||||
|
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
|
||||||
|
|
||||||
|
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
|
||||||
|
|
||||||
|
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
|
||||||
|
|
||||||
|
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
|
||||||
|
|
||||||
|
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
|
||||||
|
|
||||||
|
### 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/v2/page.tsx` (Dashboard card layout — new v2 route)
|
||||||
|
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
|
||||||
|
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
|
||||||
|
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
|
||||||
|
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
|
||||||
|
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 1–5. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
|
||||||
|
>
|
||||||
|
> - PRs 1–2 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
|
||||||
|
> - PRs 3–5 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 1–2 weeks.
|
||||||
|
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*` → `/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
|
||||||
|
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
|
||||||
|
>
|
||||||
|
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
|
||||||
|
|
||||||
|
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 v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
|
||||||
|
4. **PR 4 — Stops v2** at `/admin/v2/stops`
|
||||||
|
5. **PR 5 — Products v2** at `/admin/v2/products`
|
||||||
|
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
|
||||||
|
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
|
||||||
|
8. **PR 8 — Dashboard cutover** — redirect `/admin` → `/admin/v2`; remove old dashboard
|
||||||
|
|
||||||
|
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,18 +86,44 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
// Redirects
|
// Redirects
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
// Redirect old paths if needed
|
// Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
|
||||||
// {
|
// Redirect the v1 list/detail paths to their v2 equivalents.
|
||||||
// source: '/old-path',
|
// Use `permanent: false` (307) so we can revert easily if a
|
||||||
// destination: '/new-path',
|
// regression is caught in monitoring before the v1 files are
|
||||||
// permanent: true,
|
// removed in Task 6.2.
|
||||||
// },
|
// PR 8, Task 8.1: redirect the bare /admin path to the v2 dashboard.
|
||||||
|
// The dashboard itself still lives at /admin (as the v1 page)
|
||||||
|
// until Task 8.2 removes it after a 3-day monitoring window —
|
||||||
|
// this redirect just points the v1 entry point at v2.
|
||||||
|
{ 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 },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"next-auth": "^5.0.0-beta.31",
|
"next-auth": "^5.0.0-beta.31",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -75,6 +77,7 @@
|
|||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.5",
|
"eslint-config-next": "16.2.5",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 590 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,100 +2,32 @@
|
|||||||
"name": "Route Commerce",
|
"name": "Route Commerce",
|
||||||
"short_name": "Route Commerce",
|
"short_name": "Route Commerce",
|
||||||
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
|
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
|
||||||
"start_url": "/",
|
"start_url": "/admin",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#faf8f5",
|
"background_color": "#faf8f5",
|
||||||
"theme_color": "#1a4d2e",
|
"theme_color": "#166534",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
|
||||||
"src": "/icons/icon-72x72.png",
|
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
|
||||||
"sizes": "72x72",
|
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
|
||||||
"type": "image/png",
|
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
|
||||||
"purpose": "any maskable"
|
{ "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-96x96.png",
|
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
"sizes": "96x96",
|
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-128x128.png",
|
|
||||||
"sizes": "128x128",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-152x152.png",
|
|
||||||
"sizes": "152x152",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-384x384.png",
|
|
||||||
"sizes": "384x384",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any maskable"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"categories": ["business", "productivity"],
|
"categories": ["business", "productivity"],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{ "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
|
||||||
"name": "View Orders",
|
{ "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
|
||||||
"short_name": "Orders",
|
{ "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
|
||||||
"description": "View and manage orders",
|
|
||||||
"url": "/admin/orders",
|
|
||||||
"icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Add Product",
|
|
||||||
"short_name": "Add Product",
|
|
||||||
"description": "Add a new product",
|
|
||||||
"url": "/admin/products/new",
|
|
||||||
"icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Create Stop",
|
|
||||||
"short_name": "Create Stop",
|
|
||||||
"description": "Schedule a new pickup stop",
|
|
||||||
"url": "/admin/stops/new",
|
|
||||||
"icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"screenshots": [
|
"screenshots": [
|
||||||
{
|
{ "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
|
||||||
"src": "/screenshots/dashboard.png",
|
{ "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
|
||||||
"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": [],
|
"related_applications": [],
|
||||||
"prefer_related_applications": false
|
"prefer_related_applications": false
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!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>
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,130 +1,108 @@
|
|||||||
// Service Worker for PWA - Caching and offline support
|
// public/sw.js
|
||||||
|
// Route Commerce Service Worker
|
||||||
|
// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate)
|
||||||
|
|
||||||
const CACHE_NAME = "route-commerce-v1";
|
const SHELL_CACHE = "rc-shell-v3";
|
||||||
const OFFLINE_URL = "/offline";
|
const DATA_CACHE = "rc-data-v3";
|
||||||
|
const OFFLINE_URL = "/offline.html";
|
||||||
|
|
||||||
const STATIC_ASSETS = [
|
const SHELL_ASSETS = [
|
||||||
"/",
|
"/",
|
||||||
"/manifest.json",
|
"/manifest.json",
|
||||||
"/favicon.svg",
|
"/favicon.svg",
|
||||||
"/og-default.jpg",
|
"/og-default.svg",
|
||||||
|
"/offline.html",
|
||||||
|
"/icons/icon-192x192.png",
|
||||||
|
"/icons/icon-512x512.png",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache static assets
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||||
return cache.addAll(STATIC_ASSETS);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate event - clean old caches
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
Promise.all([
|
||||||
return Promise.all(
|
caches.keys().then((cacheNames) =>
|
||||||
|
Promise.all(
|
||||||
cacheNames
|
cacheNames
|
||||||
.filter((name) => name !== CACHE_NAME)
|
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
|
||||||
.map((name) => caches.delete(name))
|
.map((name) => caches.delete(name))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
self.clients.claim(),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
})
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch event - network first, fallback to cache
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
// Skip non-GET requests
|
const req = event.request;
|
||||||
if (event.request.method !== "GET") return;
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
// Skip API requests
|
if (url.origin !== self.location.origin) return;
|
||||||
if (event.request.url.includes("/api/")) return;
|
|
||||||
|
|
||||||
// Skip cross-origin requests
|
|
||||||
if (!event.request.url.startsWith(self.location.origin)) return;
|
|
||||||
|
|
||||||
|
// Navigations → network-first, fall back to cache, fall back to offline page
|
||||||
|
if (req.mode === "navigate") {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(req)
|
||||||
.then((response) => {
|
.then((res) => {
|
||||||
// Clone response for caching
|
const clone = res.clone();
|
||||||
const responseClone = response.clone();
|
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||||
|
return res;
|
||||||
// Cache successful responses
|
|
||||||
if (response.status === 200) {
|
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
|
||||||
cache.put(event.request, responseClone);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() =>
|
||||||
// Return cached response or offline page
|
caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
|
||||||
return caches.match(event.request).then((cachedResponse) => {
|
)
|
||||||
if (cachedResponse) {
|
);
|
||||||
return cachedResponse;
|
return;
|
||||||
}
|
}
|
||||||
// Return offline page for navigation requests
|
|
||||||
if (event.request.mode === "navigate") {
|
// Static assets → cache-first
|
||||||
return caches.match(OFFLINE_URL);
|
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 new Response("Network error", { status: 408 });
|
return res;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
return;
|
||||||
|
|
||||||
// Push notification handling
|
|
||||||
self.addEventListener("push", (event) => {
|
|
||||||
if (!event.data) return;
|
|
||||||
|
|
||||||
const data = event.data.json();
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
body: data.body,
|
|
||||||
icon: "/icons/icon-192x192.png",
|
|
||||||
badge: "/icons/badge-72x72.png",
|
|
||||||
vibrate: [100, 50, 100],
|
|
||||||
data: {
|
|
||||||
url: data.url || "/",
|
|
||||||
},
|
|
||||||
actions: data.actions || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notification click handling
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
|
|
||||||
const url = event.notification.data?.url || "/";
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
|
||||||
// Focus existing window or open new one
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url === url && "focus" in client) {
|
|
||||||
return client.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (clients.openWindow) {
|
|
||||||
return clients.openWindow(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background sync for offline actions
|
|
||||||
self.addEventListener("sync", (event) => {
|
|
||||||
if (event.tag === "sync-orders") {
|
|
||||||
event.waitUntil(syncOrders());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function syncOrders() {
|
|
||||||
// Implement order sync logic
|
|
||||||
console.log("Syncing orders in background...");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 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`);
|
||||||
|
}
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => { console.error(err); process.exit(1); });
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// 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);
|
||||||
@@ -28,6 +28,30 @@ export type DashboardSummary = {
|
|||||||
active_products: number;
|
active_products: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile-dashboard summary shape. Returned by the `get_dashboard_summary`
|
||||||
|
* RPC (migration 0091) and consumed by `/admin/v2` to power the four
|
||||||
|
* stat cards + the 7-day order chart.
|
||||||
|
*
|
||||||
|
* - `revenue_today` is in CENTS (not dollars) — divide by 100 for display.
|
||||||
|
* - `orders_last_7_days` is oldest → today (7 elements, includes today).
|
||||||
|
*/
|
||||||
|
export type MobileDashboardSummary = {
|
||||||
|
orders_today: number;
|
||||||
|
revenue_today: number;
|
||||||
|
pending_fulfillment: number;
|
||||||
|
stops_today: number;
|
||||||
|
orders_last_7_days: Array<{ date: string; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
|
||||||
|
orders_today: 0,
|
||||||
|
revenue_today: 0,
|
||||||
|
pending_fulfillment: 0,
|
||||||
|
stops_today: 0,
|
||||||
|
orders_last_7_days: [],
|
||||||
|
};
|
||||||
|
|
||||||
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
@@ -276,6 +300,48 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the mobile dashboard summary for a single brand.
|
||||||
|
*
|
||||||
|
* Different name from the existing `getDashboardSummary()` (which is the
|
||||||
|
* legacy 4-field shape used by the v1 dashboard) so both can coexist.
|
||||||
|
* Returns safe fallbacks on any error — the v2 page renders a 0s
|
||||||
|
* dashboard rather than crashing the shell.
|
||||||
|
*/
|
||||||
|
export async function getMobileDashboardSummary(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<MobileDashboardSummary> {
|
||||||
|
try {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) {
|
||||||
|
return EMPTY_MOBILE_DASHBOARD;
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query<{ data: MobileDashboardSummary }>(
|
||||||
|
"SELECT get_dashboard_summary($1::uuid) AS data",
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
const data = rows[0]?.data;
|
||||||
|
if (!data) {
|
||||||
|
return EMPTY_MOBILE_DASHBOARD;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
orders_today: Number(data.orders_today ?? 0),
|
||||||
|
revenue_today: Number(data.revenue_today ?? 0),
|
||||||
|
pending_fulfillment: Number(data.pending_fulfillment ?? 0),
|
||||||
|
stops_today: Number(data.stops_today ?? 0),
|
||||||
|
orders_last_7_days: Array.isArray(data.orders_last_7_days)
|
||||||
|
? data.orders_last_7_days.map((d) => ({
|
||||||
|
date: String(d?.date ?? ""),
|
||||||
|
count: Number(d?.count ?? 0),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch mobile dashboard summary:", error);
|
||||||
|
return EMPTY_MOBILE_DASHBOARD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatTimeAgo(dateString: string): string {
|
function formatTimeAgo(dateString: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline mutation dispatcher.
|
||||||
|
*
|
||||||
|
* The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation
|
||||||
|
* queue (src/lib/offline/queue.ts) and a sync engine
|
||||||
|
* (src/lib/offline/sync.ts) that replays queued actions when the
|
||||||
|
* device comes back online. The client side enqueues an action with a
|
||||||
|
* name + JSON payload + idempotency key (clientActionId), then calls
|
||||||
|
* `syncPending` which invokes this dispatcher for each pending action.
|
||||||
|
*
|
||||||
|
* The dispatcher is the single server-side bridge between the offline
|
||||||
|
* queue and our SECURITY DEFINER server actions. To add a new offline-
|
||||||
|
* capable action, append an entry to `listClientActions()` with:
|
||||||
|
* - a stable name (kebab- or camelCase, the client enqueues by name)
|
||||||
|
* - a handler that accepts the JSON payload and returns
|
||||||
|
* { success: true } | { success: false; error: string }
|
||||||
|
*
|
||||||
|
* The action names referenced by the plan (markOrderReady,
|
||||||
|
* markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet
|
||||||
|
* exist as server actions in the v1 codebase. They're added in
|
||||||
|
* follow-up PRs. For now the dispatcher returns `{ ok: false, error:
|
||||||
|
* "Unknown action" }` for them, which surfaces in the OfflineBanner
|
||||||
|
* conflict count. This keeps the structure in place without coupling
|
||||||
|
* PR 3 to a server-side action surface it doesn't own.
|
||||||
|
*
|
||||||
|
* Idempotency: each enqueue carries a clientActionId (uuid v4). Real
|
||||||
|
* idempotency requires a dedup table on the server — that's also a
|
||||||
|
* follow-up. Until then, replays of the same action will re-execute,
|
||||||
|
* but last-write-wins on the underlying tables is safe for the
|
||||||
|
* mutations we ship (status flips, stock adjust).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ClientAction {
|
||||||
|
name: string;
|
||||||
|
// The handler signature is intentionally loose — payload shapes vary
|
||||||
|
// per action. The dispatcher in `sync.ts` invokes this with the raw
|
||||||
|
// payload from IndexedDB.
|
||||||
|
handler: (payload: unknown, clientActionId: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listClientActions(): Promise<ClientAction[]> {
|
||||||
|
// Wire real server actions here as they land:
|
||||||
|
// { name: "markOrderReady", handler: markOrderReady },
|
||||||
|
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||||
|
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||||
|
// { name: "adjustProductStock", handler: adjustProductStock },
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchClientAction(
|
||||||
|
actionName: string,
|
||||||
|
payload: unknown,
|
||||||
|
clientActionId: string,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; conflict: string }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
const actions = await listClientActions();
|
||||||
|
const action = actions.find((a) => a.name === actionName);
|
||||||
|
if (!action) {
|
||||||
|
return { ok: false, error: "Unknown action" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await action.handler(payload, clientActionId);
|
||||||
|
if (result.success === false) {
|
||||||
|
const message = result.error ?? "Action failed";
|
||||||
|
if (/conflict|stale/i.test(message)) {
|
||||||
|
return { ok: false, conflict: message };
|
||||||
|
}
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/v2/orders");
|
||||||
|
revalidatePath("/admin/v2/orders/[id]", "page");
|
||||||
|
revalidatePath("/admin/v2/stops");
|
||||||
|
revalidatePath("/admin/v2/products");
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ToastContainer } from "@/components/admin/ToastContainer";
|
|||||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||||
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
||||||
import CommandPalette from "@/components/admin/CommandPalette";
|
import CommandPalette from "@/components/admin/CommandPalette";
|
||||||
|
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
|
||||||
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||||
|
|
||||||
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||||
@@ -135,6 +136,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
<RouteAnnouncer />
|
<RouteAnnouncer />
|
||||||
</div>
|
</div>
|
||||||
|
<PWAInstallPrompt />
|
||||||
</ToastProviderWrapper>
|
</ToastProviderWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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 { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminAccessDenied message="Failed to verify authentication." />
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminAccessDenied message="You don't have admin access." />
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.must_change_password) {
|
||||||
|
redirect("/change-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve brand + brands + addons with graceful fallbacks. A failure in
|
||||||
|
// any of these must not crash the whole shell — the AdminShell renders
|
||||||
|
// empty state (no brands) rather than a 500.
|
||||||
|
let activeBrandId: string | null = null;
|
||||||
|
try {
|
||||||
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] getActiveBrandId failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||||
|
try {
|
||||||
|
brands = await listBrandsForAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] listBrandsForAdmin failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabledAddons: Record<string, boolean> = {};
|
||||||
|
if (activeBrandId) {
|
||||||
|
try {
|
||||||
|
enabledAddons = await getEnabledAddons(activeBrandId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/v2/layout] getEnabledAddons failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProviderWrapper>
|
||||||
|
<AdminShell
|
||||||
|
userRole={adminUser.role}
|
||||||
|
brandIds={adminUser.brand_ids}
|
||||||
|
activeBrandId={activeBrandId}
|
||||||
|
brands={brands}
|
||||||
|
enabledAddons={enabledAddons}
|
||||||
|
>
|
||||||
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
<RouteAnnouncer />
|
||||||
|
</AdminShell>
|
||||||
|
</ToastProviderWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getAdminOrderDetail, type AdminOrder } 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";
|
||||||
|
import FulfillmentTimeline from "@/components/admin/orders/FulfillmentTimeline";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the legacy `orders.status` + `pickup_complete` pair onto the
|
||||||
|
* StatusPill's `StatusKind` union. Must match the helper in
|
||||||
|
* `orders/page.tsx` so the list and detail pages agree on the
|
||||||
|
* pill vocabulary. Kept inlined here to avoid a shared-types file
|
||||||
|
* until we know the mapping is stable.
|
||||||
|
*/
|
||||||
|
type OrderStatusKind = "placed" | "ready" | "picked-up" | "cancelled";
|
||||||
|
|
||||||
|
function statusKind(o: { status: string; pickup_complete: boolean }): OrderStatusKind {
|
||||||
|
if (o.status === "canceled") return "cancelled";
|
||||||
|
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
|
||||||
|
if (o.status === "confirmed") return "ready";
|
||||||
|
return "placed";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAdminOrderDetail(id);
|
||||||
|
if (!order) notFound();
|
||||||
|
|
||||||
|
const kind = statusKind(order);
|
||||||
|
const subtotal = Number(order.subtotal ?? 0);
|
||||||
|
const taxAmount = Number(order.tax_amount ?? 0);
|
||||||
|
const discountAmount = Number(order.discount_amount ?? 0);
|
||||||
|
const total = subtotal + taxAmount - discountAmount;
|
||||||
|
const itemCount = order.order_items?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="pb-40">
|
||||||
|
<PageHeader
|
||||||
|
title={`Order #${order.id.slice(0, 8).toUpperCase()}`}
|
||||||
|
subtitle={formatDate(order.created_at)}
|
||||||
|
actions={<StatusPill status={kind as StatusKind} />}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{order.stops && (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl p-4"
|
||||||
|
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-label font-semibold mb-1"
|
||||||
|
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
|
||||||
|
>
|
||||||
|
PICKUP
|
||||||
|
</h3>
|
||||||
|
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||||||
|
{order.stops.city}, {order.stops.state}
|
||||||
|
</div>
|
||||||
|
{order.stops.date && (
|
||||||
|
<div
|
||||||
|
className="text-meta mt-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{formatDate(order.stops.date)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FulfillmentTimeline
|
||||||
|
status={kind}
|
||||||
|
placedAt={order.created_at}
|
||||||
|
pickedUpAt={order.pickup_completed_at}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 ({itemCount})
|
||||||
|
</h3>
|
||||||
|
{itemCount > 0 ? (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{order.order_items?.map((item: NonNullable<AdminOrder["order_items"]>[number]) => {
|
||||||
|
const unitPrice = Number(item.price ?? 0);
|
||||||
|
const lineTotal = unitPrice * item.quantity;
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-h2" style={{ fontWeight: 700 }}>
|
||||||
|
{item.products?.name ?? "Unknown product"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-meta"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{item.quantity} × ${unitPrice.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-body font-semibold">
|
||||||
|
${lineTotal.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="text-body"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No items
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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 }}
|
||||||
|
>
|
||||||
|
${total.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{order.internal_notes && (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl p-4 italic"
|
||||||
|
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||||
|
>
|
||||||
|
<p className="text-body">“{order.internal_notes}”</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StickyActionBar>
|
||||||
|
<OrderActionButtons orderId={order.id} status={kind} />
|
||||||
|
</StickyActionBar>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getAdminOrders } 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the legacy `orders.status` + `pickup_complete` pair onto the
|
||||||
|
* StatusPill's `StatusKind` union. The legacy schema uses
|
||||||
|
* ('pending' | 'confirmed' | 'fulfilled' | 'canceled') and a boolean
|
||||||
|
* `pickup_complete`; the v2 design uses the simpler
|
||||||
|
* (placed | ready | picked-up | cancelled) vocabulary.
|
||||||
|
*/
|
||||||
|
function statusKind(o: { status: string; pickup_complete: boolean }): StatusKind {
|
||||||
|
if (o.status === "canceled") return "cancelled";
|
||||||
|
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
|
||||||
|
if (o.status === "confirmed") return "ready";
|
||||||
|
return "placed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OrdersV2Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ status?: string }>;
|
||||||
|
}) {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const result = await getAdminOrders();
|
||||||
|
const allOrders = result.success ? result.orders : [];
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
// The legacy RPC doesn't accept a status filter — apply it client-side.
|
||||||
|
// The shape we filter on matches the StatusPill vocabulary (placed/ready/
|
||||||
|
// picked-up/cancelled), not the legacy DB status string, so the user
|
||||||
|
// intent is the same.
|
||||||
|
const filtered = params.status
|
||||||
|
? allOrders.filter((o) => statusKind(o) === params.status)
|
||||||
|
: allOrders;
|
||||||
|
|
||||||
|
// Show at most 50 — the plan's example limit. Anything more is paginated
|
||||||
|
// (PR follow-up).
|
||||||
|
const orders = filtered.slice(0, 50);
|
||||||
|
|
||||||
|
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={<span aria-hidden="true">📦</span>}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="Orders"
|
||||||
|
subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`}
|
||||||
|
actions={<OrdersFilterButton />}
|
||||||
|
/>
|
||||||
|
<PullToRefresh>
|
||||||
|
<CardList ariaLabel="Orders">
|
||||||
|
{orders.map((order) => {
|
||||||
|
const total = Number(order.subtotal ?? 0);
|
||||||
|
return (
|
||||||
|
<CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<StatusPill status={statusKind(order)} />
|
||||||
|
<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="flex items-center justify-between mt-2">
|
||||||
|
<span
|
||||||
|
className="text-h1 font-display"
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
${total.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: "var(--color-text-faint)" }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardList>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getMobileDashboardSummary } from "@/actions/dashboard";
|
||||||
|
import { getAdminOrders } from "@/actions/orders";
|
||||||
|
import PageHeader from "@/components/admin/PageHeader";
|
||||||
|
import EmptyState from "@/components/admin/EmptyState";
|
||||||
|
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||||
|
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||||
|
import { formatDate, formatRelativeTime } from "@/lib/format-date";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row shape for the "Today's stops" inline query. Slimmer than the
|
||||||
|
* full v2 stops page shape — the dashboard only needs the time + a
|
||||||
|
* displayable address. The query mirrors `src/app/admin/v2/stops/page.tsx`'s
|
||||||
|
* `s.date = $1` pattern (stops uses a `date` DATE column, not
|
||||||
|
* `scheduled_at`).
|
||||||
|
*/
|
||||||
|
interface DashboardStopRow {
|
||||||
|
id: string;
|
||||||
|
time: string | null;
|
||||||
|
location: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
brand_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the legacy order status onto the StatusPill vocabulary. Mirrors
|
||||||
|
* the v2 orders page's `statusKind()` — `'pending' | 'confirmed'` is
|
||||||
|
* the v1 schema's "needs attention" set (everything before fulfillment).
|
||||||
|
*/
|
||||||
|
function orderStatusKind(status: string): StatusKind {
|
||||||
|
if (status === "canceled") return "cancelled";
|
||||||
|
if (status === "fulfilled") return "picked-up";
|
||||||
|
if (status === "confirmed") return "ready";
|
||||||
|
return "placed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a stop's free-form time string (24h or 12h) for display. */
|
||||||
|
function formatStopTime(time: string | null): string {
|
||||||
|
if (!time) return "—";
|
||||||
|
const t = time.trim();
|
||||||
|
if (/[ap]m$/i.test(t)) return t.toUpperCase();
|
||||||
|
const m = t.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (m) {
|
||||||
|
let h = parseInt(m[1], 10);
|
||||||
|
const min = m[2];
|
||||||
|
const ampm = h >= 12 ? "PM" : "AM";
|
||||||
|
if (h === 0) h = 12;
|
||||||
|
else if (h > 12) h -= 12;
|
||||||
|
return `${h}:${min} ${ampm}`;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the best address string we have on a stop row. */
|
||||||
|
function stopAddress(stop: DashboardStopRow): string {
|
||||||
|
if (stop.address) return stop.address;
|
||||||
|
if (stop.location) return stop.location;
|
||||||
|
const cityState = [stop.city, stop.state].filter(Boolean).join(", ");
|
||||||
|
return cityState || "Stop";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the "today" YYYY-MM-DD string in the server's local timezone.
|
||||||
|
* Kept in sync with `src/app/admin/v2/stops/page.tsx` so a dashboard
|
||||||
|
* click-through lands on the matching stops list.
|
||||||
|
*/
|
||||||
|
function todayDateStr(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardV2Page() {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
|
if (!activeBrandId) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<EmptyState
|
||||||
|
title="No brand selected"
|
||||||
|
description="Select a brand to view its dashboard."
|
||||||
|
icon={<span aria-hidden="true">🌾</span>}
|
||||||
|
/>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandId = activeBrandId;
|
||||||
|
const dateStr = todayDateStr();
|
||||||
|
|
||||||
|
// Parallel fetch: stat summary (RPC), today's stops, and pending
|
||||||
|
// orders. The stops query mirrors the v2 stops page's pool pattern;
|
||||||
|
// `getAdminOrders` is the shared legacy RPC.
|
||||||
|
const [summary, stopsResult, ordersResult] = await Promise.all([
|
||||||
|
getMobileDashboardSummary(brandId),
|
||||||
|
pool.query<DashboardStopRow>(
|
||||||
|
`SELECT s.id, s."time", s.location, s.address, s.city, s.state, s.brand_id
|
||||||
|
FROM stops s
|
||||||
|
WHERE s.date = $1 AND s.brand_id = $2
|
||||||
|
ORDER BY s."time" ASC NULLS LAST, s.location ASC
|
||||||
|
LIMIT 5`,
|
||||||
|
[dateStr, brandId],
|
||||||
|
),
|
||||||
|
getAdminOrders(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const todayStops = stopsResult.rows;
|
||||||
|
const allOrders = ordersResult.success ? ordersResult.orders : [];
|
||||||
|
const pendingOrders = allOrders
|
||||||
|
.filter((o) => o.status === "pending" || o.status === "confirmed")
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const hasAnyData =
|
||||||
|
summary.orders_today > 0 ||
|
||||||
|
summary.revenue_today > 0 ||
|
||||||
|
summary.pending_fulfillment > 0 ||
|
||||||
|
summary.stops_today > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<div className="px-4 pb-24 space-y-4">
|
||||||
|
{/* "Needs you" callout — surfaces the most actionable count
|
||||||
|
* at the top of the page. Tapping jumps to the filtered
|
||||||
|
* orders list. */}
|
||||||
|
{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, lineHeight: 1.3 }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2x2 stat grid. The "Pending" card picks up the warning
|
||||||
|
* accent when there's anything to act on. */}
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-2 gap-3"
|
||||||
|
aria-label="Today's stats"
|
||||||
|
>
|
||||||
|
<StatCard
|
||||||
|
label="Orders"
|
||||||
|
value={summary.orders_today.toLocaleString("en-US")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Revenue"
|
||||||
|
value={`$${(summary.revenue_today / 100).toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Pending"
|
||||||
|
value={summary.pending_fulfillment.toLocaleString("en-US")}
|
||||||
|
accent={summary.pending_fulfillment > 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Stops"
|
||||||
|
value={summary.stops_today.toLocaleString("en-US")}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Today's stops — at-a-glance list of the next 5 runs so
|
||||||
|
* the operator can see where they need to be today. */}
|
||||||
|
{todayStops.length > 0 && (
|
||||||
|
<section aria-label="Today's stops">
|
||||||
|
<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">
|
||||||
|
{todayStops.map((stop) => {
|
||||||
|
const href = `/admin/v2/stops?date=${dateStr}`;
|
||||||
|
return (
|
||||||
|
<li key={stop.id}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-surface-2)",
|
||||||
|
minHeight: "72px",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-meta"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{formatStopTime(stop.time)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-h2 mt-1"
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stopAddress(stop)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top 5 pending orders — drilled into from the "Needs you"
|
||||||
|
* callout above. Each row tappable to the order detail. */}
|
||||||
|
{pendingOrders.length > 0 && (
|
||||||
|
<section aria-label="Pending orders">
|
||||||
|
<h2
|
||||||
|
className="text-label font-semibold mb-3"
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PENDING ORDERS
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pendingOrders.map((order) => {
|
||||||
|
const total = Number(order.subtotal ?? 0);
|
||||||
|
return (
|
||||||
|
<li key={order.id}>
|
||||||
|
<Link
|
||||||
|
href={`/admin/v2/orders/${order.id}`}
|
||||||
|
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-surface-2)",
|
||||||
|
minHeight: "72px",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
|
<StatusPill status={orderStatusKind(order.status)} />
|
||||||
|
<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,
|
||||||
|
color: "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{order.customer_name ?? "Customer"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-h1 mt-1"
|
||||||
|
style={{ fontWeight: 600, color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
${total.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty dashboard — no orders, no revenue, no stops. Keep
|
||||||
|
* the page useful instead of showing four zeros. */}
|
||||||
|
{!hasAnyData && (
|
||||||
|
<EmptyState
|
||||||
|
title="Nothing to show yet"
|
||||||
|
description="When orders, stops, or revenue roll in, they'll show up here."
|
||||||
|
icon={<span aria-hidden="true">🌱</span>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single stat tile. Mirrors the v2 product/stop cards' rounded-2xl +
|
||||||
|
* surface-2 background. `accent` swaps the fill + value color to the
|
||||||
|
* warning pair so the "Pending" card stands out when there's work to do.
|
||||||
|
*/
|
||||||
|
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)",
|
||||||
|
minHeight: "88px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: accent ? "var(--color-warning)" : "var(--color-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
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 PullToRefresh from "@/components/admin/PullToRefresh";
|
||||||
|
import ProductsFilterChips from "@/components/admin/products/ProductsFilterChips";
|
||||||
|
import StockAdjustButton from "@/components/admin/products/StockAdjustButton";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row shape for the v2 products list. Mirrors the columns we actually
|
||||||
|
* select from the `products` table — the plan's speculative shape used
|
||||||
|
* `stock` / `image_url` / `price_cents` which is close, but the real
|
||||||
|
* stock column is `inventory` (not `stock`), `image_url` and
|
||||||
|
* `price_cents` are correct as-is, and there is no `hidden` field
|
||||||
|
* (visibility is the boolean `active`).
|
||||||
|
*/
|
||||||
|
interface DbProductRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price_cents: number;
|
||||||
|
inventory: number;
|
||||||
|
image_url: string | null;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an inventory count onto the StatusPill vocabulary. Matches the
|
||||||
|
* thresholds the v1 admin Products page uses (out = 0, low < 10, else in
|
||||||
|
* stock). The `kind` doubles as a CSS-friendly token; the `label` is the
|
||||||
|
* user-visible string (includes the count so the user can read it at
|
||||||
|
* a glance in the field).
|
||||||
|
*/
|
||||||
|
function stockStatus(inventory: number): { kind: StatusKind; label: string } {
|
||||||
|
if (inventory === 0) return { kind: "out", label: "Out of stock" };
|
||||||
|
if (inventory < 10) return { kind: "low", label: `⚠ ${inventory} left` };
|
||||||
|
return { kind: "in-stock", label: `${inventory} in stock` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inventory/active filter clause for a given `?filter=` value.
|
||||||
|
*
|
||||||
|
* - "all" → no filter
|
||||||
|
* - "in-stock" → inventory >= 10
|
||||||
|
* - "low" → 0 < inventory < 10
|
||||||
|
* - "out" → inventory = 0
|
||||||
|
* - "hidden" → active = false (no separate `hidden` column in the
|
||||||
|
* schema — "hidden" maps to the legacy "inactive" state)
|
||||||
|
* - unknown → same as "all" (defensive: don't 500 on a bad URL)
|
||||||
|
*/
|
||||||
|
function filterClause(filter: string | undefined): { sql: string; params: unknown[] } {
|
||||||
|
switch (filter) {
|
||||||
|
case "in-stock":
|
||||||
|
return { sql: " AND inventory >= 10", params: [] };
|
||||||
|
case "low":
|
||||||
|
return { sql: " AND inventory > 0 AND inventory < 10", params: [] };
|
||||||
|
case "out":
|
||||||
|
return { sql: " AND inventory = 0", params: [] };
|
||||||
|
case "hidden":
|
||||||
|
return { sql: " AND active = false", params: [] };
|
||||||
|
case "all":
|
||||||
|
default:
|
||||||
|
return { sql: "", params: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductsV2Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ filter?: string }>;
|
||||||
|
}) {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
const params = await searchParams;
|
||||||
|
const filter = params.filter;
|
||||||
|
const { sql: filterSql, params: filterParams } = filterClause(filter);
|
||||||
|
|
||||||
|
// Mirror the v2 stops page's brand-scoping pattern: explicit active
|
||||||
|
// brand > platform_admin "all brands" > multi-brand membership list.
|
||||||
|
let products: DbProductRow[] = [];
|
||||||
|
let queryError: string | null = null;
|
||||||
|
try {
|
||||||
|
const selectFields = `
|
||||||
|
id, name, price_cents, inventory, image_url, active, brand_id
|
||||||
|
`;
|
||||||
|
const orderBy = `name ASC`;
|
||||||
|
|
||||||
|
if (activeBrandId) {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = $1 ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[activeBrandId, ...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
} else if (adminUser.role === "platform_admin") {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE 1=1 ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
} else {
|
||||||
|
const brandIds = adminUser.brand_ids ?? [];
|
||||||
|
if (brandIds.length === 0) {
|
||||||
|
products = [];
|
||||||
|
} else {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = ANY($1::uuid[]) ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[brandIds, ...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
queryError = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[admin/v2/products] query error:", queryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryError) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Products" />
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<div className="px-4 pb-24">
|
||||||
|
<EmptyState
|
||||||
|
title="Couldn't load products"
|
||||||
|
description={queryError}
|
||||||
|
icon={<span aria-hidden="true">⚠️</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Products" />
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<EmptyState
|
||||||
|
title="No products"
|
||||||
|
description="Add your first product to start selling."
|
||||||
|
icon={<span aria-hidden="true">🥬</span>}
|
||||||
|
/>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="Products"
|
||||||
|
subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<CardList ariaLabel="Products">
|
||||||
|
{products.map((product) => {
|
||||||
|
const stock = stockStatus(product.inventory);
|
||||||
|
// price_cents → display dollars, two decimals. The schema
|
||||||
|
// stores integer cents, so the legacy `price` field (in
|
||||||
|
// dollars) would be `price_cents / 100`.
|
||||||
|
const priceDollars = (product.price_cents / 100).toFixed(2);
|
||||||
|
return (
|
||||||
|
<CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{product.image_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- product
|
||||||
|
// images may live on a third-party CDN; Next/Image
|
||||||
|
// would require us to whitelist the host. A plain
|
||||||
|
// <img> is fine here since this is a mobile-first
|
||||||
|
// admin page, not a public storefront.
|
||||||
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 rounded-2xl object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-2xl shrink-0 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--color-surface-3)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
🥬
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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.inventory} />
|
||||||
|
</div>
|
||||||
|
<div className="text-h1 font-display mt-1" style={{ fontWeight: 600 }}>${priceDollars}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<StatusPill status={stock.kind} label={stock.label} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardList>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
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 PullToRefresh from "@/components/admin/PullToRefresh";
|
||||||
|
import StopsDatePicker from "@/components/admin/stops/StopsDatePicker";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row shape returned from the stops query. The legacy `stops` table uses
|
||||||
|
* separate `date` (DATE) and `time` (TEXT) columns, not a combined
|
||||||
|
* `scheduled_at` ISO string — the plan's example was speculative about
|
||||||
|
* the schema, so we mirror the v1 stops page shape and adapt.
|
||||||
|
*/
|
||||||
|
interface DbStopRow {
|
||||||
|
id: string;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
date: string;
|
||||||
|
time: string | null;
|
||||||
|
location: string;
|
||||||
|
status: string;
|
||||||
|
brand_id: string;
|
||||||
|
address: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
cutoff_date: string | null;
|
||||||
|
customer_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the legacy `stops.status` ('active' | 'paused' | 'closed') onto the
|
||||||
|
* StatusPill's `StatusKind` vocabulary. Mirrors the v1 page's
|
||||||
|
* `s.status === "active"` check but extends it to cover the full enum.
|
||||||
|
*
|
||||||
|
* - 'active' → 'scheduled' (default — accepting orders)
|
||||||
|
* - 'paused' → 'in-transit' (temporarily closed, like a truck en route)
|
||||||
|
* - 'closed' → 'completed' (final state)
|
||||||
|
*/
|
||||||
|
function stopStatusKind(s: string): StatusKind {
|
||||||
|
if (s === "paused") return "in-transit";
|
||||||
|
if (s === "closed") return "completed";
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the hour (0-23) from a free-form time string. The `stops.time`
|
||||||
|
* column is TEXT and accepts both 24h ("14:30") and 12h ("9:00 AM",
|
||||||
|
* "9 AM", "2:30pm") formats. Returns null for unparseable input so
|
||||||
|
* those stops fall into the "Anytime" bucket.
|
||||||
|
*/
|
||||||
|
function parseHour(time: string | null): number | null {
|
||||||
|
if (!time) return null;
|
||||||
|
const t = time.trim();
|
||||||
|
// 12h format with optional minutes: "9:00 AM" / "9 AM" / "9:00am" / "2:30 PM"
|
||||||
|
const m12 = t.match(/^(\d{1,2})(?::\d{2})?\s*(am|pm)$/i);
|
||||||
|
if (m12) {
|
||||||
|
let h = parseInt(m12[1], 10);
|
||||||
|
const isPm = m12[2].toLowerCase() === "pm";
|
||||||
|
if (h < 1 || h > 12) return null;
|
||||||
|
if (h === 12) h = isPm ? 12 : 0;
|
||||||
|
else if (isPm) h += 12;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
// 24h format: "14:30" or "14"
|
||||||
|
const m24 = t.match(/^(\d{1,2})(?::\d{2})?$/);
|
||||||
|
if (m24) {
|
||||||
|
const h = parseInt(m24[1], 10);
|
||||||
|
return h >= 0 && h < 24 ? h : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeBucket = "Morning" | "Afternoon" | "Evening" | "Anytime";
|
||||||
|
|
||||||
|
function bucketFor(time: string | null): TimeBucket {
|
||||||
|
const h = parseHour(time);
|
||||||
|
if (h === null) return "Anytime";
|
||||||
|
if (h < 12) return "Morning";
|
||||||
|
if (h < 17) return "Afternoon";
|
||||||
|
return "Evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUCKET_ORDER: TimeBucket[] = ["Morning", "Afternoon", "Evening", "Anytime"];
|
||||||
|
|
||||||
|
/** Build a Google Maps directions URL for a stop's address. */
|
||||||
|
function mapsUrlFor(stop: DbStopRow): string | null {
|
||||||
|
const parts = [stop.address, stop.city, stop.state, stop.zip].filter(Boolean);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
|
||||||
|
parts.join(", "),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateHeading(d: Date): string {
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(time: string | null): string {
|
||||||
|
if (!time) return "—";
|
||||||
|
const t = time.trim();
|
||||||
|
// If it's already a "9:00 AM" or "9:00AM" style, return as-is
|
||||||
|
if (/[ap]m$/i.test(t)) return t.toUpperCase();
|
||||||
|
// 24h "14:30" → "2:30 PM"
|
||||||
|
const m = t.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (m) {
|
||||||
|
let h = parseInt(m[1], 10);
|
||||||
|
const min = m[2];
|
||||||
|
const ampm = h >= 12 ? "PM" : "AM";
|
||||||
|
if (h === 0) h = 12;
|
||||||
|
else if (h > 12) h -= 12;
|
||||||
|
return `${h}:${min} ${ampm}`;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StopsV2Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ date?: string }>;
|
||||||
|
}) {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
// Resolve the target date. `searchParams.date` is a YYYY-MM-DD string.
|
||||||
|
// Fall back to "today" in the user's local timezone (not UTC, so the
|
||||||
|
// displayed heading matches what they expect).
|
||||||
|
let dateObj: Date;
|
||||||
|
if (params.date) {
|
||||||
|
const parsed = new Date(params.date);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
dateObj = parsed;
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const now = new Date();
|
||||||
|
dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
}
|
||||||
|
const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// Build the brand filter. Mirrors the v1 stops page's brand-scoping
|
||||||
|
// pattern: explicit active brand > platform_admin "all brands" >
|
||||||
|
// multi-brand membership list.
|
||||||
|
let stops: DbStopRow[] = [];
|
||||||
|
let error: string | null = null;
|
||||||
|
try {
|
||||||
|
const selectFields = `
|
||||||
|
s.id, s.city, s.state, s.date::text AS date, s."time", s.location,
|
||||||
|
s.status, s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||||
|
(SELECT COUNT(*)::int FROM orders o WHERE o.stop_id = s.id) AS customer_count
|
||||||
|
`;
|
||||||
|
const orderBy = `s."time" ASC NULLS LAST, s.location ASC`;
|
||||||
|
const baseWhere = `s.date = $1`;
|
||||||
|
|
||||||
|
if (activeBrandId) {
|
||||||
|
const result = await pool.query<DbStopRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM stops s
|
||||||
|
WHERE ${baseWhere} AND s.brand_id = $2
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[dateStr, activeBrandId],
|
||||||
|
);
|
||||||
|
stops = result.rows;
|
||||||
|
} else if (adminUser.role === "platform_admin") {
|
||||||
|
const result = await pool.query<DbStopRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM stops s
|
||||||
|
WHERE ${baseWhere}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[dateStr],
|
||||||
|
);
|
||||||
|
stops = result.rows;
|
||||||
|
} else {
|
||||||
|
const brandIds = adminUser.brand_ids ?? [];
|
||||||
|
if (brandIds.length === 0) {
|
||||||
|
// No accessible brands → empty list, no need to query.
|
||||||
|
stops = [];
|
||||||
|
} else {
|
||||||
|
const result = await pool.query<DbStopRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM stops s
|
||||||
|
WHERE ${baseWhere} AND s.brand_id = ANY($2::uuid[])
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[dateStr, brandIds],
|
||||||
|
);
|
||||||
|
stops = result.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[admin/v2/stops] query error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerDate = dateObj;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Stops" actions={<StopsDatePicker value={headerDate} />} />
|
||||||
|
<div className="px-4 pb-24">
|
||||||
|
<EmptyState
|
||||||
|
title="Couldn't load stops"
|
||||||
|
description={error}
|
||||||
|
icon={<span aria-hidden="true">⚠️</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stops.length === 0) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="Stops"
|
||||||
|
subtitle={formatDateHeading(headerDate)}
|
||||||
|
actions={<StopsDatePicker value={headerDate} />}
|
||||||
|
/>
|
||||||
|
<PullToRefresh>
|
||||||
|
<EmptyState
|
||||||
|
title="No stops scheduled"
|
||||||
|
description="Add a stop to start routing."
|
||||||
|
icon={<span aria-hidden="true">🚚</span>}
|
||||||
|
/>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by time-of-day bucket. Use Map to preserve insertion order.
|
||||||
|
const grouped: Record<TimeBucket, DbStopRow[]> = {
|
||||||
|
Morning: [],
|
||||||
|
Afternoon: [],
|
||||||
|
Evening: [],
|
||||||
|
Anytime: [],
|
||||||
|
};
|
||||||
|
for (const s of stops) {
|
||||||
|
grouped[bucketFor(s.time)].push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="Stops"
|
||||||
|
subtitle={formatDateHeading(headerDate)}
|
||||||
|
actions={<StopsDatePicker value={headerDate} />}
|
||||||
|
/>
|
||||||
|
<PullToRefresh>
|
||||||
|
{BUCKET_ORDER.filter((b) => grouped[b].length > 0).map((bucket) => (
|
||||||
|
<section key={bucket}>
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bucket.toUpperCase()}
|
||||||
|
</h2>
|
||||||
|
<CardList ariaLabel={`${bucket} stops`}>
|
||||||
|
{grouped[bucket].map((stop) => {
|
||||||
|
const href = mapsUrlFor(stop);
|
||||||
|
return (
|
||||||
|
<CardListItem key={stop.id} href={href ?? "#"}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="text-h1 font-display shrink-0"
|
||||||
|
style={{ fontWeight: 600, minWidth: "72px" }}
|
||||||
|
>
|
||||||
|
{formatTimeLabel(stop.time)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-h2"
|
||||||
|
style={{ fontWeight: 700, lineHeight: 1.3 }}
|
||||||
|
>
|
||||||
|
{stop.address ??
|
||||||
|
stop.location ??
|
||||||
|
([stop.city, stop.state].filter(Boolean).join(", ") ||
|
||||||
|
"Stop")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-meta mt-1"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{stop.customer_count} customer
|
||||||
|
{stop.customer_count === 1 ? "" : "s"}
|
||||||
|
{stop.cutoff_date != null
|
||||||
|
? ` · Cutoff ${stop.cutoff_date}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={stopStatusKind(stop.status)} />
|
||||||
|
</div>
|
||||||
|
</CardListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardList>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,6 +75,87 @@
|
|||||||
--color-citrus-900: #7c2d12;
|
--color-citrus-900: #7c2d12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Field Almanac palette — values are intentionally equal to the existing
|
||||||
|
* --color-surface-50 (== --color-surface-2) and --color-surface-100
|
||||||
|
* (== --color-surface-3). The new tokens live in :root (not @theme) so
|
||||||
|
* they don't collide with the existing @theme token names. The duplication
|
||||||
|
* is intentional until the legacy @theme palette is consolidated. */
|
||||||
|
/* ─── 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 every surface (see design-tokens.test.ts) */
|
||||||
|
--color-text: #1d1d1f;
|
||||||
|
--color-text-muted: #424245;
|
||||||
|
--color-text-faint: #5e5e63; /* AA on every surface (>= 5.28:1) */
|
||||||
|
|
||||||
|
/* Accent / status — darkened to pass AAA on the surfaces they actually
|
||||||
|
* appear on (bg, surface, and their -soft pill backgrounds). The darker
|
||||||
|
* hues (green-900 / red-900 / amber-900) intentionally trade a touch of
|
||||||
|
* vibrancy for legibility in outdoor / sun-glare conditions. */
|
||||||
|
--color-accent: #14532d; /* AAA on bg (9.11), surface (8.59), s2 (8.37), s3 (7.46) */
|
||||||
|
--color-accent-2: #166534; /* hover state — used as button background, NOT text */
|
||||||
|
--color-accent-soft: #dcfce7; /* pill background; accent text on it = 8.36:1 */
|
||||||
|
--color-danger: #7f1d1d; /* AAA on bg (10.02), surface (9.45), s2 (9.20), s3 (8.20) */
|
||||||
|
--color-danger-soft: #fee2e2;
|
||||||
|
--color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */
|
||||||
|
--color-warning-soft: #fef9c3;
|
||||||
|
--color-success: #14532d; /* mirrors accent — both are "positive" semantics */
|
||||||
|
--color-success-soft: #dcfce7;
|
||||||
|
--color-info: #1e3a8a; /* AAA on every surface (8.48–10.36) */
|
||||||
|
--color-info-soft: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Type scale (HIG Accessibility-tier) ─── */
|
||||||
|
:root {
|
||||||
|
--text-display: 32px;
|
||||||
|
--text-display--line-height: 1.15;
|
||||||
|
--text-display--letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
--text-h1: 24px;
|
||||||
|
--text-h1--line-height: 1.2;
|
||||||
|
|
||||||
|
--text-h2: 19px;
|
||||||
|
--text-h2--line-height: 1.3;
|
||||||
|
|
||||||
|
--text-body: 18px;
|
||||||
|
--text-body--line-height: 1.45;
|
||||||
|
|
||||||
|
--text-label: 15px;
|
||||||
|
--text-label--line-height: 1.3;
|
||||||
|
--text-label--letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
--text-mono: 14px;
|
||||||
|
--text-mono--line-height: 1.4;
|
||||||
|
|
||||||
|
--text-meta: 13px;
|
||||||
|
--text-meta--line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Spacing (8pt grid) ─── */
|
||||||
|
:root {
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Motion ─── */
|
||||||
|
:root {
|
||||||
|
--ease-apple: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-base: 200ms;
|
||||||
|
--duration-slow: 320ms;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Base ─────────────────────────────────────────────────────── */
|
/* ─── Base ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
@@ -213,6 +294,14 @@ select:-webkit-autofill:focus {
|
|||||||
100% { transform: translateX(400%); }
|
100% { transform: translateX(400%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spinner for the PullToRefresh indicator. The indicator uses
|
||||||
|
* `border-top-color` to give the impression of one bright edge rotating.
|
||||||
|
* Reduced-motion users get `animation: none` from the component, so this
|
||||||
|
* keyframe doesn't need a motion-guard. */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduce motion: respect the user's OS-level preference. */
|
/* Reduce motion: respect the user's OS-level preference. */
|
||||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||||
* but flattens everything that fights vestibular comfort. This block wipes:
|
* but flattens everything that fights vestibular comfort. This block wipes:
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const fragmentMono = Fragment_Mono({
|
|||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
themeColor: "#0a0a0a",
|
themeColor: "#166534",
|
||||||
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -41,6 +42,25 @@ export const metadata: Metadata = {
|
|||||||
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments.",
|
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments.",
|
||||||
keywords: ["wholesale produce", "farm fresh", "B2B e-commerce", "produce distribution", "pickup stops", "fresh produce"],
|
keywords: ["wholesale produce", "farm fresh", "B2B e-commerce", "produce distribution", "pickup stops", "fresh produce"],
|
||||||
metadataBase: new URL(BASE_URL),
|
metadataBase: new URL(BASE_URL),
|
||||||
|
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 },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Route Commerce | Fresh Produce Wholesale Platform",
|
title: "Route Commerce | Fresh Produce Wholesale Platform",
|
||||||
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { MotionConfig } from "framer-motion";
|
import { MotionConfig } from "framer-motion";
|
||||||
import { CartProvider } from "@/context/CartContext";
|
import { CartProvider } from "@/context/CartContext";
|
||||||
@@ -8,9 +9,13 @@ import SiteHeader from "@/components/layout/SiteHeader";
|
|||||||
import SiteFooter from "@/components/layout/SiteFooter";
|
import SiteFooter from "@/components/layout/SiteFooter";
|
||||||
import CartToast from "@/components/storefront/CartToast";
|
import CartToast from "@/components/storefront/CartToast";
|
||||||
import CartRestoredToast from "@/components/cart/CartRestoredToast";
|
import CartRestoredToast from "@/components/cart/CartRestoredToast";
|
||||||
|
import { registerServiceWorker } from "@/lib/pwa";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
useEffect(() => {
|
||||||
|
registerServiceWorker();
|
||||||
|
}, []);
|
||||||
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
|
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
|
||||||
const isLandingPage = pathname === "/" || pathname === "/brands";
|
const isLandingPage = pathname === "/" || pathname === "/brands";
|
||||||
const isAuthPage = pathname === "/login" || pathname === "/admin/login";
|
const isAuthPage = pathname === "/login" || pathname === "/admin/login";
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||||
|
import MobileTabBar from "@/components/admin/MobileTabBar";
|
||||||
|
import OfflineBanner from "@/components/admin/OfflineBanner";
|
||||||
|
import { useMediaQuery } from "@/lib/use-media-query"; // see Task 1.9
|
||||||
|
|
||||||
|
interface AdminShellProps {
|
||||||
|
userRole: string | null;
|
||||||
|
brandIds?: string[];
|
||||||
|
activeBrandId?: string | null;
|
||||||
|
brands: { id: string; name: string; slug: string; logo_url: string | null }[];
|
||||||
|
enabledAddons?: Record<string, boolean>;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminShell(props: AdminShellProps) {
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminSidebar
|
||||||
|
userRole={props.userRole}
|
||||||
|
brandIds={props.brandIds}
|
||||||
|
activeBrandId={props.activeBrandId}
|
||||||
|
brands={props.brands}
|
||||||
|
enabledAddons={props.enabledAddons}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="page-content"
|
||||||
|
className="min-h-screen lg:pl-60 outline-none"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OfflineBanner />
|
||||||
|
<div
|
||||||
|
id="page-content"
|
||||||
|
className="min-h-screen pb-20 outline-none"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
<MobileTabBar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminShell;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "More" sheet shown when the user taps the "More" tab in the mobile
|
||||||
|
* bottom tab bar (src/components/admin/MobileTabBar.tsx).
|
||||||
|
*
|
||||||
|
* NOTE: The primary navigation tabs (Orders / Stops / Products / Dashboard)
|
||||||
|
* live in the bottom tab bar itself, NOT in this sheet. This sheet
|
||||||
|
* intentionally exposes only the secondary operations so the tab bar can
|
||||||
|
* stay focused on the high-frequency workflows. If you need to add a
|
||||||
|
* primary tab here, do it in MobileTabBar instead.
|
||||||
|
*
|
||||||
|
* (PR 6 cutover: the v1 /admin/orders, /admin/stops, /admin/products
|
||||||
|
* paths now 307-redirect to /admin/v2/* via next.config.ts — see
|
||||||
|
* `redirects()` there.)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom pull-to-refresh — no third-party library.
|
||||||
|
*
|
||||||
|
* Only engages when the page is scrolled to the top (`window.scrollY === 0`).
|
||||||
|
* Uses rubberband resistance so the pull feels heavy past a comfortable
|
||||||
|
* range, and clamps the visible offset at MAX_PULL. The release animation
|
||||||
|
* snaps back to 0 (or to REFRESH_INDICATOR_HEIGHT if a refresh is in
|
||||||
|
* flight). Calls `router.refresh()` so the server component re-renders
|
||||||
|
* with fresh data.
|
||||||
|
*
|
||||||
|
* Respects `prefers-reduced-motion: reduce` — the transition is disabled
|
||||||
|
* and the offset still updates, but the snap-back doesn't animate. The
|
||||||
|
* indicator spinner animation is also dropped under reduced motion.
|
||||||
|
*/
|
||||||
|
export function PullToRefresh({ children }: PullToRefreshProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
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);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
if (refreshing) return;
|
||||||
|
// Only start a pull if the page is scrolled to the top — pulling
|
||||||
|
// mid-scroll would hijack normal scroll.
|
||||||
|
if (window.scrollY > 0) return;
|
||||||
|
startY.current = e.touches[0].clientY;
|
||||||
|
pulling.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
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. The actual data
|
||||||
|
// fetch happens on the server; this is just the "spinner shown
|
||||||
|
// for a beat" feel.
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
setRefreshing(false);
|
||||||
|
setOffset(0);
|
||||||
|
} else {
|
||||||
|
setOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 && !prefersReducedMotion ? "spin 0.8s linear infinite" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PullToRefresh;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal stepper showing the order's progression:
|
||||||
|
* Placed → Ready → Picked up.
|
||||||
|
*
|
||||||
|
* Derived from the legacy `orders` row:
|
||||||
|
* - Placed: always set (created_at)
|
||||||
|
* - Ready: orders.status = 'confirmed' (no dedicated timestamp in the
|
||||||
|
* legacy schema; we surface "—" rather than fabricating one)
|
||||||
|
* - Picked up: orders.pickup_complete = true → pickup_completed_at
|
||||||
|
*
|
||||||
|
* Cancelled orders get a single "Cancelled" pill instead of the stepper
|
||||||
|
* — the timeline doesn't make sense for a voided order.
|
||||||
|
*/
|
||||||
|
export type FulfillmentStatus = "placed" | "ready" | "picked-up" | "cancelled";
|
||||||
|
|
||||||
|
interface FulfillmentTimelineProps {
|
||||||
|
status: FulfillmentStatus;
|
||||||
|
placedAt: string;
|
||||||
|
pickedUpAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: { key: FulfillmentStatus; label: string }[] = [
|
||||||
|
{ key: "placed", label: "Placed" },
|
||||||
|
{ key: "ready", label: "Ready" },
|
||||||
|
{ key: "picked-up", label: "Picked up" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stepState(
|
||||||
|
step: FulfillmentStatus,
|
||||||
|
current: FulfillmentStatus,
|
||||||
|
): "done" | "current" | "upcoming" {
|
||||||
|
const order = STEPS.findIndex((s) => s.key === step);
|
||||||
|
const currentIdx = STEPS.findIndex((s) => s.key === current);
|
||||||
|
if (currentIdx === -1) return "upcoming";
|
||||||
|
if (order < currentIdx) return "done";
|
||||||
|
if (order === currentIdx) return "current";
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FulfillmentTimeline({
|
||||||
|
status,
|
||||||
|
placedAt,
|
||||||
|
pickedUpAt,
|
||||||
|
}: FulfillmentTimelineProps) {
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl p-4"
|
||||||
|
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-label font-semibold mb-2"
|
||||||
|
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
|
||||||
|
>
|
||||||
|
STATUS
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className="text-h2"
|
||||||
|
style={{ fontWeight: 700, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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" }}
|
||||||
|
>
|
||||||
|
FULFILLMENT
|
||||||
|
</h3>
|
||||||
|
<ol className="flex items-start gap-2" aria-label="Fulfillment timeline">
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const state = stepState(step.key, status);
|
||||||
|
const isLast = i === STEPS.length - 1;
|
||||||
|
// Pick the right timestamp to display. The "Ready" step has no
|
||||||
|
// dedicated column in the legacy schema, so we show "—" — the
|
||||||
|
// user can read the mark-ready time from the audit log.
|
||||||
|
const ts =
|
||||||
|
step.key === "placed"
|
||||||
|
? placedAt
|
||||||
|
: step.key === "picked-up"
|
||||||
|
? pickedUpAt
|
||||||
|
: null;
|
||||||
|
const dotBg =
|
||||||
|
state === "done"
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: state === "current"
|
||||||
|
? "var(--color-accent-2)"
|
||||||
|
: "var(--color-surface-3)";
|
||||||
|
const labelColor =
|
||||||
|
state === "upcoming" ? "var(--color-text-faint)" : "var(--color-text)";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.key}
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
aria-current={state === "current" ? "step" : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full shrink-0"
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
backgroundColor: dotBg,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{!isLast && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-1 h-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
state === "done"
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "var(--color-surface-3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-label font-semibold mt-2"
|
||||||
|
style={{ color: labelColor, letterSpacing: "0.02em" }}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-mono mt-1"
|
||||||
|
style={{ color: "var(--color-text-faint)" }}
|
||||||
|
>
|
||||||
|
{ts ? formatDateTime(ts) : "—"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FulfillmentTimeline;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky action bar buttons for the v2 order detail page.
|
||||||
|
*
|
||||||
|
* Maps the v2 status vocabulary to the v2 action vocabulary:
|
||||||
|
* - placed → enqueue "markOrderReady" → backend: toggle pickup_complete=false
|
||||||
|
* - ready → enqueue "markOrderPickedUp" → backend: toggle pickup_complete=true
|
||||||
|
* - picked-up → render "View Receipt" (outlined) — links to the v1 receipt page
|
||||||
|
* - cancelled → render the "View Receipt" fallback (no primary action)
|
||||||
|
*
|
||||||
|
* The action is enqueued to IndexedDB and replayed via the
|
||||||
|
* `syncPending` engine, which calls `dispatchClientAction` for each
|
||||||
|
* pending record. The StickyActionBar's `pendingSync` badge is shown
|
||||||
|
* while the queue has work, and the page is `router.refresh()`-ed on
|
||||||
|
* success so the server component re-renders with the new status.
|
||||||
|
*
|
||||||
|
* NOTE: the dispatcher (`src/actions/offline-dispatcher.ts`) currently
|
||||||
|
* returns "Unknown action" for both markOrderReady and markOrderPickedUp
|
||||||
|
* because the matching server actions don't ship in PR 3. The UI
|
||||||
|
* surface (queue, conflict, refresh) is in place — wiring the real
|
||||||
|
* handlers is a follow-up. Until then, clicks will surface in the
|
||||||
|
* OfflineBanner's "failed" count and the user sees a brief "Marking…"
|
||||||
|
* state. This matches the plan: PR 3 is structure, not the full
|
||||||
|
* happy path.
|
||||||
|
*/
|
||||||
|
export function OrderActionButtons({ orderId, status }: OrderActionButtonsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null);
|
||||||
|
const [pendingSync, setPendingSync] = useState(false);
|
||||||
|
|
||||||
|
async function handleAction(actionName: string) {
|
||||||
|
setPending(true);
|
||||||
|
setConflict(null);
|
||||||
|
setPendingSync(true);
|
||||||
|
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.");
|
||||||
|
} else if (result.synced > 0) {
|
||||||
|
// Successful replay — the server invalidated the path; pull fresh data.
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
// result.failed: the action wasn't recognized. The OfflineBanner will
|
||||||
|
// surface the failure count; we don't inline an error here.
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
setPendingSync(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflict) {
|
||||||
|
return (
|
||||||
|
<div className="text-body" style={{ color: "var(--color-danger)" }}>
|
||||||
|
{conflict}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
data-pending-sync={pendingSync ? "true" : "false"}
|
||||||
|
>
|
||||||
|
{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",
|
||||||
|
}}
|
||||||
|
data-pending-sync={pendingSync ? "true" : "false"}
|
||||||
|
>
|
||||||
|
{pending ? "Marking…" : "Mark Picked Up"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter button for the Orders list. Opens a native <dialog> as a
|
||||||
|
* right-edge sheet (matching the MoreSheet pattern) with status options.
|
||||||
|
* Picking a status pushes a new URL with the `status` search param —
|
||||||
|
* the server component re-renders with the filter applied.
|
||||||
|
*
|
||||||
|
* The status values here are the v2 StatusPill vocabulary
|
||||||
|
* (placed/ready/picked-up/cancelled) which is what the orders page
|
||||||
|
* filters on. Empty string = clear filter (all orders).
|
||||||
|
*/
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Sync the dialog's open state when the user closes via Escape
|
||||||
|
// (native <dialog> behavior — fires a "close" event, not a React event).
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const onClose = () => setOpen(false);
|
||||||
|
dialog.addEventListener("close", onClose);
|
||||||
|
return () => dialog.removeEventListener("close", onClose);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter chips for the v2 Products list.
|
||||||
|
*
|
||||||
|
* The plan's filter vocabulary was "all" | "in-stock" | "low" | "out" | "hidden".
|
||||||
|
* The real `products` schema has no `stock` or `hidden` columns — stock is
|
||||||
|
* stored in `inventory` and visibility in `active` (no separate "hidden" flag).
|
||||||
|
* The mapping is:
|
||||||
|
* - "all" → no filter
|
||||||
|
* - "in-stock" → inventory >= 10
|
||||||
|
* - "low" → inventory > 0 AND inventory < 10
|
||||||
|
* - "out" → inventory = 0
|
||||||
|
* - "hidden" → active = false (treating "hidden" as "not visible to customers")
|
||||||
|
*
|
||||||
|
* Filter state is encoded in the `?filter=` query param so the server
|
||||||
|
* component can re-render with the filter applied (no client-side state).
|
||||||
|
*/
|
||||||
|
const FILTERS = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "in-stock", label: "In stock" },
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "out", label: "Out" },
|
||||||
|
{ value: "hidden", label: "Hidden" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press stepper to adjust a product's `inventory` count.
|
||||||
|
*
|
||||||
|
* UI plumbing only: pressing +/- enqueues an `adjustProductStock` mutation
|
||||||
|
* into the offline IndexedDB queue and triggers a sync pass. The server-side
|
||||||
|
* `adjustProductStock` action is not yet wired into the dispatcher's
|
||||||
|
* `listClientActions()` table (PR 3 deviation) — `dispatchClientAction` will
|
||||||
|
* return `{ok: false, error: "Unknown action"}` for now, which surfaces in
|
||||||
|
* the OfflineBanner conflict count. That's the known follow-up; the queue +
|
||||||
|
* sync pipeline is the deliverable here.
|
||||||
|
*
|
||||||
|
* The "long press" affordance keeps accidental single-tap stock changes to
|
||||||
|
* a minimum — a single tap on the trigger does nothing; the dialog only
|
||||||
|
* opens after holding the button for LONG_PRESS_MS. Inside the dialog the
|
||||||
|
* stepper buttons are large (64px) for gloved use.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface StopsDatePickerProps {
|
||||||
|
value: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date as the `YYYY-MM-DD` value used by `<input type="date">`.
|
||||||
|
*
|
||||||
|
* Uses local-time components (not `toISOString()`) so the picker doesn't
|
||||||
|
* snap to the day before/after for users in a non-UTC timezone — a date
|
||||||
|
* picker where selecting "today" in the user's browser unexpectedly
|
||||||
|
* changes the URL by a day is a common footgun.
|
||||||
|
*/
|
||||||
|
function toInputValue(d: Date): string {
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StopsDatePicker — native `<input type="date">` for the v2 stops list.
|
||||||
|
*
|
||||||
|
* Pushing a new URL on change triggers a server re-render of the page
|
||||||
|
* with the new date. The page's `searchParams.date` is the source of
|
||||||
|
* truth; this component is purely an input control.
|
||||||
|
*
|
||||||
|
* The pill is sized at 56px to match the rest of the v2 action bar
|
||||||
|
* (matching OrdersFilterButton).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
if (!newDate) return;
|
||||||
|
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;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// src/lib/__tests__/design-tokens.test.ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts all token combinations meet WCAG contrast requirements for the
|
||||||
|
* surfaces they actually render on. Rationale: outdoor + sun-glare is
|
||||||
|
* harder than WCAG accounts for, so we aim for AAA (7:1) on the
|
||||||
|
* surfaces text appears on, and AA (4.5:1) as the documented floor.
|
||||||
|
*
|
||||||
|
* Surface roles:
|
||||||
|
* bg — page background
|
||||||
|
* surface — card / panel background
|
||||||
|
* surface-2 — input fields, secondary cards
|
||||||
|
* surface-3 — high-emphasis containers, skeletons, dividers
|
||||||
|
*
|
||||||
|
* Token roles (and which assertion groups they belong to):
|
||||||
|
* text / text-muted — body text, appears on all 4 surfaces → AAA
|
||||||
|
* text-faint — meta / timestamps, appears on all 4 surfaces → AA
|
||||||
|
* accent / success — brand + positive status text → AAA on bg, surface
|
||||||
|
* danger / warning / info — status text, on bg + surface + their -soft pill bg → AAA
|
||||||
|
* accent-2 — button background, white text sits on top → AAA
|
||||||
|
* *-soft — pill background, distinct from page surface (decorative)
|
||||||
|
*/
|
||||||
|
|
||||||
|
type RGB = [number, number, number];
|
||||||
|
|
||||||
|
function parseHex(hex: string): RGB {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
return [
|
||||||
|
parseInt(h.slice(0, 2), 16),
|
||||||
|
parseInt(h.slice(2, 4), 16),
|
||||||
|
parseInt(h.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbToLinear(c: number): number {
|
||||||
|
const v = c / 255;
|
||||||
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance([r, g, b]: RGB): number {
|
||||||
|
return (
|
||||||
|
0.2126 * srgbToLinear(r) +
|
||||||
|
0.7152 * srgbToLinear(g) +
|
||||||
|
0.0722 * srgbToLinear(b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(fg: string, bg: string): number {
|
||||||
|
const l1 = luminance(parseHex(fg));
|
||||||
|
const l2 = luminance(parseHex(bg));
|
||||||
|
const lighter = Math.max(l1, l2);
|
||||||
|
const darker = Math.min(l1, l2);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SURFACES: Record<string, string> = {
|
||||||
|
bg: "#ffffff",
|
||||||
|
surface: "#faf8f5",
|
||||||
|
"surface-2": "#f5f5f7",
|
||||||
|
"surface-3": "#e8e8ed",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page surfaces that status text actually renders on. Status text does
|
||||||
|
// not render directly on --color-surface-3 (skeleton/dividers); it lives
|
||||||
|
// on its own -soft pill background instead.
|
||||||
|
const PAGE_SURFACES: Record<string, string> = {
|
||||||
|
bg: "#ffffff",
|
||||||
|
surface: "#faf8f5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BODY_TEXT_AAA: Record<string, string> = {
|
||||||
|
text: "#1d1d1f",
|
||||||
|
"text-muted": "#424245",
|
||||||
|
info: "#1e3a8a",
|
||||||
|
};
|
||||||
|
|
||||||
|
const META_TEXT_AA: Record<string, string> = {
|
||||||
|
"text-faint": "#5e5e63",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TEXT_AAA: Record<string, string> = {
|
||||||
|
accent: "#14532d",
|
||||||
|
danger: "#7f1d1d",
|
||||||
|
warning: "#5e2a04",
|
||||||
|
success: "#14532d",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ON_SOFT: Record<string, { text: string; soft: string }> = {
|
||||||
|
"accent on accent-soft": { text: "#14532d", soft: "#dcfce7" },
|
||||||
|
"danger on danger-soft": { text: "#7f1d1d", soft: "#fee2e2" },
|
||||||
|
"warning on warning-soft": { text: "#5e2a04", soft: "#fef9c3" },
|
||||||
|
"success on success-soft": { text: "#14532d", soft: "#dcfce7" },
|
||||||
|
"info on info-soft": { text: "#1e3a8a", soft: "#dbeafe" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ON_ACCENT_BUTTON = {
|
||||||
|
fg: "#ffffff", // button label
|
||||||
|
bg: "#166534", // --color-accent-2
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("design tokens — WCAG contrast", () => {
|
||||||
|
describe("body text on every page surface (AAA)", () => {
|
||||||
|
Object.entries(BODY_TEXT_AAA).forEach(([token, hex]) => {
|
||||||
|
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||||
|
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
|
||||||
|
const ratio = contrastRatio(hex, surfaceHex);
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`,
|
||||||
|
).toBeGreaterThanOrEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("meta text on every page surface (AA)", () => {
|
||||||
|
Object.entries(META_TEXT_AA).forEach(([token, hex]) => {
|
||||||
|
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||||
|
it(`${token} on ${surfaceName} meets AA (>= 4.5:1)`, () => {
|
||||||
|
const ratio = contrastRatio(hex, surfaceHex);
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`,
|
||||||
|
).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status text on page surfaces (AAA)", () => {
|
||||||
|
Object.entries(STATUS_TEXT_AAA).forEach(([token, hex]) => {
|
||||||
|
Object.entries(PAGE_SURFACES).forEach(([surfaceName, surfaceHex]) => {
|
||||||
|
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
|
||||||
|
const ratio = contrastRatio(hex, surfaceHex);
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`,
|
||||||
|
).toBeGreaterThanOrEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status text on its -soft pill background (AAA)", () => {
|
||||||
|
Object.entries(STATUS_ON_SOFT).forEach(([name, { text, soft }]) => {
|
||||||
|
it(`${name} meets AAA (>= 7:1)`, () => {
|
||||||
|
const ratio = contrastRatio(text, soft);
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`${name}: ${text} on ${soft} = ${ratio.toFixed(2)}:1`,
|
||||||
|
).toBeGreaterThanOrEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("button text on accent-2 background (AAA)", () => {
|
||||||
|
it(`white on accent-2 meets AAA (>= 7:1)`, () => {
|
||||||
|
const ratio = contrastRatio(ON_ACCENT_BUTTON.fg, ON_ACCENT_BUTTON.bg);
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`white on ${ON_ACCENT_BUTTON.bg} = ${ratio.toFixed(2)}:1`,
|
||||||
|
).toBeGreaterThanOrEqual(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pill backgrounds are visually distinct from page surface", () => {
|
||||||
|
// The "soft" backgrounds are decorative. They don't need to pass
|
||||||
|
// contrast on their own, but they must read as a distinct fill
|
||||||
|
// against the page surface.
|
||||||
|
const softs: Record<string, string> = {
|
||||||
|
"accent-soft": "#dcfce7",
|
||||||
|
"danger-soft": "#fee2e2",
|
||||||
|
"warning-soft": "#fef9c3",
|
||||||
|
"success-soft": "#dcfce7",
|
||||||
|
"info-soft": "#dbeafe",
|
||||||
|
};
|
||||||
|
Object.entries(softs).forEach(([token, hex]) => {
|
||||||
|
it(`${token} reads as a distinct fill on --color-surface`, () => {
|
||||||
|
const ratio = contrastRatio(hex, "#faf8f5");
|
||||||
|
expect(
|
||||||
|
ratio,
|
||||||
|
`${token} too close to surface (${ratio.toFixed(2)}:1)`,
|
||||||
|
).toBeLessThan(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,3 +37,27 @@ export function formatTime(date: string | Date | null | undefined): string {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Just now" / "5m ago" / "2h ago" / "yesterday" / "3d ago" / MM/DD/YYYY.
|
||||||
|
*
|
||||||
|
* Used by the v2 admin Orders list (CardList time-ago badge) and any
|
||||||
|
* other place that needs a humanized, glanceable timestamp without
|
||||||
|
* pulling in a date library. Buckets are deliberately coarse — anything
|
||||||
|
* past a week is shown as the absolute date (MM/DD/YYYY) via formatDate.
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(input: string | Date | number): string {
|
||||||
|
const date = input instanceof Date ? input : new Date(input);
|
||||||
|
if (isNaN(date.getTime())) return "—";
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
// src/lib/offline/queue.test.ts
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import {
|
||||||
|
enqueueAction,
|
||||||
|
dequeueAction,
|
||||||
|
getQueuedActions,
|
||||||
|
markSynced,
|
||||||
|
markConflict,
|
||||||
|
markInFlight,
|
||||||
|
markFailed,
|
||||||
|
} 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markInFlight transitions the action to in-flight and records an attempt", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
const before = await getQueuedActions();
|
||||||
|
expect(before[0].attempts).toBe(0);
|
||||||
|
expect(before[0].status).toBe("pending");
|
||||||
|
expect(before[0].lastAttemptAt).toBeNull();
|
||||||
|
|
||||||
|
const beforeMs = Date.now();
|
||||||
|
await markInFlight(a.id);
|
||||||
|
const afterMs = Date.now();
|
||||||
|
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("in-flight");
|
||||||
|
expect(all[0].attempts).toBe(1);
|
||||||
|
expect(typeof all[0].lastAttemptAt).toBe("number");
|
||||||
|
expect(all[0].lastAttemptAt).not.toBeNull();
|
||||||
|
expect(all[0].lastAttemptAt!).toBeGreaterThanOrEqual(beforeMs);
|
||||||
|
expect(all[0].lastAttemptAt!).toBeLessThanOrEqual(afterMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markFailed transitions the action to failed and increments attempts", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
// Pretend it has already been tried once
|
||||||
|
await markInFlight(a.id);
|
||||||
|
|
||||||
|
const beforeMs = Date.now();
|
||||||
|
await markFailed(a.id, "server returned 500");
|
||||||
|
const afterMs = Date.now();
|
||||||
|
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].attempts).toBe(2); // markInFlight set it to 1, markFailed adds 1
|
||||||
|
expect(all[0].error).toBe("server returned 500");
|
||||||
|
expect(typeof all[0].lastAttemptAt).toBe("number");
|
||||||
|
expect(all[0].lastAttemptAt).not.toBeNull();
|
||||||
|
expect(all[0].lastAttemptAt!).toBeGreaterThanOrEqual(beforeMs);
|
||||||
|
expect(all[0].lastAttemptAt!).toBeLessThanOrEqual(afterMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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();
|
||||||
|
// Atomic read-then-write inside a single readwrite transaction so two
|
||||||
|
// concurrent calls with the same explicit `id` can't both pass the
|
||||||
|
// existence check and write twice.
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const existing = await tx.store.get(id);
|
||||||
|
if (existing) {
|
||||||
|
await tx.done;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const action: QueuedAction = {
|
||||||
|
id,
|
||||||
|
actionName,
|
||||||
|
payload,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
attempts: 0,
|
||||||
|
lastAttemptAt: null,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
|
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 tx = db.transaction("queue", "readwrite");
|
||||||
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action.status = "in-flight";
|
||||||
|
action.lastAttemptAt = Date.now();
|
||||||
|
action.attempts += 1;
|
||||||
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tx = db.transaction("queue", "readwrite");
|
||||||
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action.status = "conflict";
|
||||||
|
action.error = error;
|
||||||
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markFailed(id: string, error: string): Promise<void> {
|
||||||
|
const db = await getOfflineDB();
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const action = await tx.store.get(id);
|
||||||
|
if (!action) {
|
||||||
|
await tx.done;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action.status = "failed";
|
||||||
|
action.error = error;
|
||||||
|
action.attempts += 1;
|
||||||
|
action.lastAttemptAt = Date.now();
|
||||||
|
await tx.store.put(action);
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending is a no-op for an empty queue and does not call the dispatcher", async () => {
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ synced: 0, conflicts: 0, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending moves an action to failed once max attempts is reached", async () => {
|
||||||
|
// Seed an action that's already at the max-attempt threshold
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "max-attempts-id" });
|
||||||
|
const { getOfflineDB } = await import("./db");
|
||||||
|
const db = await getOfflineDB();
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const a = await tx.store.get("max-attempts-id");
|
||||||
|
if (!a) throw new Error("seed not found");
|
||||||
|
a.attempts = MAX_ATTEMPTS; // 5
|
||||||
|
await tx.store.put(a);
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].error).toBe("max attempts reached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending skips actions whose backoff window has not yet elapsed", async () => {
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" }, { id: "backoff-id" });
|
||||||
|
// Pretend a previous attempt happened 100ms ago. With attempts=0, the
|
||||||
|
// backoff schedule says we must wait 1000ms — so we should skip.
|
||||||
|
const fixedNow = 1_700_000_000_000;
|
||||||
|
const { getOfflineDB } = await import("./db");
|
||||||
|
const db = await getOfflineDB();
|
||||||
|
const tx = db.transaction("queue", "readwrite");
|
||||||
|
const a = await tx.store.get("backoff-id");
|
||||||
|
if (!a) throw new Error("seed not found");
|
||||||
|
a.attempts = 0;
|
||||||
|
a.lastAttemptAt = fixedNow - 100;
|
||||||
|
await tx.store.put(a);
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, {
|
||||||
|
online: true,
|
||||||
|
sleep: () => Promise.resolve(),
|
||||||
|
now: () => fixedNow,
|
||||||
|
});
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
|
||||||
|
// Action is still pending (not advanced to in-flight or failed)
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending marks the action as failed when the dispatcher throws", async () => {
|
||||||
|
const a = await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
const dispatcher = vi.fn().mockRejectedValue(new Error("network down"));
|
||||||
|
const result = await syncPending(dispatcher, { online: true, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).toHaveBeenCalledWith("markOrderReady", { orderId: "abc" }, a.id);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.synced).toBe(0);
|
||||||
|
expect(result.conflicts).toBe(0);
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].status).toBe("failed");
|
||||||
|
expect(all[0].error).toBe("network down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncPending leaves queued actions untouched when offline even if the queue is non-empty", async () => {
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "abc" });
|
||||||
|
await enqueueAction("markOrderReady", { orderId: "def" });
|
||||||
|
const dispatcher = vi.fn();
|
||||||
|
const result = await syncPending(dispatcher, { online: false, sleep: () => Promise.resolve() });
|
||||||
|
expect(dispatcher).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ synced: 0, conflicts: 0, failed: 0 });
|
||||||
|
|
||||||
|
const { getQueuedActions } = await import("./queue");
|
||||||
|
const all = await getQueuedActions();
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
expect(all.every((x) => x.status === "pending")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-entrancy guard: concurrent `online` events shouldn't kick off parallel
|
||||||
|
// sync passes that race on the same `markInFlight`/`markSynced` records.
|
||||||
|
let inFlightSync: Promise<SyncResult> | null = null;
|
||||||
|
|
||||||
|
export async function syncPending(dispatcher: Dispatcher, options: SyncOptions): Promise<SyncResult> {
|
||||||
|
if (inFlightSync) return inFlightSync;
|
||||||
|
inFlightSync = (async () => {
|
||||||
|
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 if ("error" in dispatchResult) {
|
||||||
|
await markFailed(action.id, dispatchResult.error);
|
||||||
|
result.failed += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A single poisoned record (IDB closed, quota, etc.) must not abort
|
||||||
|
// the whole sync pass. Swallow the secondary failure with a warn so
|
||||||
|
// it stays observable in dev.
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
try {
|
||||||
|
await markFailed(action.id, message);
|
||||||
|
} catch (innerErr) {
|
||||||
|
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
|
||||||
|
}
|
||||||
|
result.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await inFlightSync;
|
||||||
|
} finally {
|
||||||
|
inFlightSync = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// tests/mobile-admin/offline-queue.spec.ts
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Sign in as a test admin (requires the seed admin from db/seed.ts).
|
||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// tests/mobile-admin/orders-list.spec.ts
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Sign in as a test admin (requires the seed admin from db/seed.ts).
|
||||||
|
// The mobile-admin project assumes `test-admin@example.com /
|
||||||
|
// test-password` is provisioned. If this isn't the case locally, run
|
||||||
|
// `npm run db:seed` first.
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// tests/mobile-admin/orders-visual.spec.ts
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Sign in as a test admin (requires the seed admin from db/seed.ts).
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// tests/mobile-admin/stops-visual.spec.ts
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } }); // iPhone 13
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Sign in as a test admin (requires the seed admin from db/seed.ts).
|
||||||
|
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("stops list — iPhone 13", async ({ page }) => {
|
||||||
|
await page.goto("/admin/v2/stops");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// The page must render the v2 shell scaffolding. A 48pt-tall date
|
||||||
|
// picker sits in the action slot; the stops list is grouped by
|
||||||
|
// time-of-day (Morning / Afternoon / Evening / Anytime).
|
||||||
|
await expect(page.locator('input[type="date"]')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page).toHaveScreenshot("stops-list-iphone13.png", {
|
||||||
|
fullPage: true,
|
||||||
|
maxDiffPixels: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops list — empty state (date with no stops)", async ({ page }) => {
|
||||||
|
// Pick a date deep in the past so no stops are scheduled.
|
||||||
|
await page.goto("/admin/v2/stops?date=2000-01-01");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// The empty state copy is the source of truth.
|
||||||
|
await expect(
|
||||||
|
page.getByText("No stops scheduled", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -14,7 +14,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
|
include: [
|
||||||
|
"tests/unit/**/*.test.ts",
|
||||||
|
"tests/unit/**/*.test.tsx",
|
||||||
|
"src/lib/__tests__/**/*.test.ts",
|
||||||
|
"src/lib/__tests__/**/*.test.tsx",
|
||||||
|
"src/lib/offline/**/*.test.ts",
|
||||||
|
],
|
||||||
exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"],
|
exclude: ["node_modules", ".next", "tests/e2e/**", "tests/login/**", "tests/smoke.spec.ts"],
|
||||||
// Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are
|
// Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are
|
||||||
// stubbed in each test — keep the timeout generous.
|
// stubbed in each test — keep the timeout generous.
|
||||||
|
|||||||