merge: admin mobile PWA redesign (8 PRs, mobile-first admin shell + offline queue + PWA infra + v2 dashboard)
Deploy to route.crispygoat.com / deploy (push) Successful in 5m15s

This commit is contained in:
Tyler
2026-06-17 19:13:13 -06:00
73 changed files with 4425 additions and 293 deletions
+34
View File
@@ -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
@@ -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).';
@@ -51,22 +51,25 @@ Add a new `@theme` block immediately after the existing one (Tailwind v4 support
--color-surface-2: #f5f5f7;
--color-surface-3: #e8e8ed;
/* Text — all AAA on --color-surface (14.6 / 9.4) except --text-faint (5.3 / AA only) */
/* Text — AAA on every surface except --text-faint (AA only, ≥ 5.28:1) */
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-text-faint: #6e6e73;
--color-text-faint: #5e5e63; /* AA on every surface (≥ 5.28:1) */
/* Accent / status — AAA on --color-surface */
--color-accent: #166534; /* primary actions, brand */
--color-accent-2: #15803d; /* hover state */
--color-accent-soft: #dcfce7; /* tinted backgrounds, accent at ~10% */
--color-danger: #b91c1c; /* errors, destructive */
/* 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: #854d0e; /* warnings, low stock */
--color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */
--color-warning-soft: #fef9c3;
--color-success: #166534; /* completed, positive */
--color-success: #14532d; /* mirrors accent */
--color-success-soft: #dcfce7;
--color-info: #1e40af; /* informational */
--color-info: #1e3a8a; /* AAA on every surface (8.4810.36) */
--color-info-soft: #dbeafe;
}
@@ -152,12 +155,24 @@ git commit -m "feat(design): add Field Almanac tokens (type, color, spacing, mot
import { describe, it, expect } from "vitest";
/**
* Asserts all token combinations meet WCAG contrast requirements.
* - AAA = 7:1 (every text token vs every surface it appears on), EXCEPT --color-text-faint
* which is documented as AA-only.
* - AA = 4.5:1 (for --color-text-faint).
* - Large text (18pt+ or 14pt+ bold) gets a relaxed 3:1 floor, but we require AAA anyway
* because outdoor + sun glare is harder than AAA accounts for.
* 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];
@@ -199,43 +214,98 @@ const SURFACES: Record<string, string> = {
"surface-3": "#e8e8ed",
};
const TEXT_TOKENS_AAA: Record<string, string> = {
text: "#1d1d1f",
"text-muted": "#424245",
accent: "#166534",
"accent-2": "#15803d",
danger: "#b91c1c",
warning: "#854d0e",
success: "#166534",
info: "#1e40af",
// 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 TEXT_FAINTS_AA: Record<string, string> = {
"text-faint": "#6e6e73",
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", () => {
Object.entries(TEXT_TOKENS_AAA).forEach(([token, hex]) => {
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => {
const ratio = contrastRatio(hex, surfaceHex);
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(7);
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);
});
});
});
});
Object.entries(TEXT_FAINTS_AA).forEach(([token, hex]) => {
Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => {
it(`${token} on ${surfaceName} meets AA (>= 4.5:1) [documented exception]`, () => {
const ratio = contrastRatio(hex, surfaceHex);
expect(ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
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);
});
});
});
});
it("status-pill backgrounds (soft variants) read on surface-2", () => {
// The "soft" status backgrounds are intended for filled pills, not body text.
// We only assert they're visually distinct from the surface they sit on.
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",
@@ -244,8 +314,10 @@ describe("design tokens — WCAG contrast", () => {
"info-soft": "#dbeafe",
};
Object.entries(softs).forEach(([token, hex]) => {
const ratio = contrastRatio(hex, "#faf8f5");
expect(ratio, `${token} too close to surface`).toBeLessThan(1.5);
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);
});
});
});
});
@@ -254,7 +326,7 @@ describe("design tokens — WCAG contrast", () => {
- [ ] **Step 2: Run the test**
Run: `npx vitest run src/lib/__tests__/design-tokens.test.ts`
Expected: 35+ tests, all PASS. (4 text AAA × 4 surfaces = 16, plus 1 text-faint AA × 4 surfaces = 4, plus 5 soft variant tests = 25 total. If some combinations aren't reachable in production code, narrow the SURFACES object; the goal is to cover what ships.)
Expected: 35 tests, all PASS. (3 body text AAA × 4 surfaces = 12, plus 1 meta text AA × 4 surfaces = 4, plus 4 status text AAA × 2 page surfaces = 8, plus 5 status-on-soft AAA = 5, plus 1 button-text AAA = 1, plus 5 soft-distinct = 5, total = 35. If a real product surface is missing from the matrix, add it to `SURFACES` or `PAGE_SURFACES`; the goal is to cover what ships.)
- [ ] **Step 3: Commit**
@@ -838,7 +910,7 @@ interface AdminShellProps {
userRole: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands: { id: string; name: string; slug: string }[];
brands: { id: string; name: string; slug: string; logo_url: string | null }[];
enabledAddons?: Record<string, boolean>;
children: ReactNode;
}
@@ -1189,10 +1261,11 @@ These are small, focused, presentational. Build them all in one PR by following
**Files:**
- Create: `src/components/admin/PageHeader.tsx`
- Create: `src/components/admin/StatusPill.tsx`
- Create: `src/components/admin/EmptyState.tsx`
- Create: `src/components/admin/CardList.tsx`
- Create: `src/components/admin/StickyActionBar.tsx`
> **Note:** `EmptyState.tsx` already exists in the repo and is in active use by `DashboardClient.tsx` and `ProductsClient.tsx` (re-exported via `design-system/index.tsx`). Reusing that component in the new mobile shell — do not create a duplicate.
- [ ] **Step 1: `PageHeader.tsx`**
```tsx
@@ -1265,34 +1338,7 @@ export function StatusPill({ status, label }: StatusPillProps) {
export default StatusPill;
```
- [ ] **Step 3: `EmptyState.tsx`**
```tsx
// src/components/admin/EmptyState.tsx
import { ReactNode } from "react";
interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
icon?: string;
}
export function EmptyState({ title, description, action, icon = "✨" }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center text-center px-6 py-16">
<div className="text-6xl mb-4" aria-hidden="true">{icon}</div>
<h2 className="text-h1 font-display mb-2" style={{ fontWeight: 600 }}>{title}</h2>
{description && <p className="text-body max-w-md" style={{ color: "var(--color-text-muted)" }}>{description}</p>}
{action && <div className="mt-6">{action}</div>}
</div>
);
}
export default EmptyState;
```
- [ ] **Step 4: `CardList.tsx` + `CardListItem.tsx`**
- [ ] **Step 3: `CardList.tsx` + `CardListItem.tsx`**
```tsx
// src/components/admin/CardList.tsx
@@ -1357,8 +1403,6 @@ export function CardListItem({ onClick, href, children, as = "li" }: CardListIte
}
return <li>{content}</li>;
}
export { CardListItem };
```
- [ ] **Step 5: `StickyActionBar.tsx`**
@@ -1405,8 +1449,8 @@ Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/components/admin/PageHeader.tsx src/components/admin/StatusPill.tsx src/components/admin/EmptyState.tsx src/components/admin/CardList.tsx src/components/admin/StickyActionBar.tsx
git commit -m "feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar"
git add src/components/admin/PageHeader.tsx src/components/admin/StatusPill.tsx src/components/admin/CardList.tsx src/components/admin/StickyActionBar.tsx
git commit -m "feat(admin): add PageHeader, StatusPill, CardList, StickyActionBar"
```
### Task 1.13: Add Lighthouse CI workflow
@@ -72,23 +72,25 @@ Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
### Color tokens (AAA contrast on `surface`)
### Color tokens (AAA contrast where text appears)
| Token | Value | Use | Contrast on `#faf8f5` |
| ------------------ | --------- | --------------------------------------- | --------------------- |
| `bg` | `#ffffff` | App background | — |
| `surface` | `#faf8f5` | Page background (warm cream) | — |
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
| `surface-3` | `#e8e8ed` | Hover / pressed | — |
| `text` | `#1d1d1f` | Primary text | 14.6 : 1 |
| `text-muted` | `#424245` | Secondary text | 9.4 : 1 |
| `text-faint` | `#6e6e73` | Tertiary / meta | 5.3 : 1 (AA only) |
| `accent` | `#166534` | Primary actions, brand | 8.2 : 1 |
| `accent-2` | `#15803d` | Hover state | 7.4 : 1 |
| `danger` | `#b91c1c` | Errors, destructive | 7.1 : 1 |
| `warning` | `#854d0e` | Warnings, low stock | 7.6 : 1 |
| `success` | `#166534` | Success, completed | 8.2 : 1 |
| `info` | `#1e40af` | Informational | 9.0 : 1 |
| 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.4810.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.
@@ -348,7 +350,7 @@ Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG AAA (7:1) on `surface` and `surface-2` backgrounds; fails CI if a new color regresses
- `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)
@@ -364,7 +366,7 @@ Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines
**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's 5.3:1, passing AA only). 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.
**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)
+26
View File
@@ -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"
}
}
}
}
+32 -6
View File
@@ -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
async redirects() {
return [
// Redirect old paths if needed
// {
// source: '/old-path',
// destination: '/new-path',
// permanent: true,
// },
// Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
// Redirect the v1 list/detail paths to their v2 equivalents.
// Use `permanent: false` (307) so we can revert easily if a
// regression is caught in monitoring before the v1 files are
// 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 },
];
},
+3
View File
@@ -42,6 +42,7 @@
"exceljs": "^4.4.0",
"framer-motion": "^12.40.0",
"gsap": "^3.15.0",
"idb": "^8.0.3",
"lucide-react": "^1.17.0",
"next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
@@ -61,6 +62,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
@@ -75,6 +77,7 @@
"drizzle-kit": "^0.30.6",
"eslint": "^9",
"eslint-config-next": "16.2.5",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"pg": "^8.20.0",
"playwright": "^1.59.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+18 -86
View File
@@ -2,101 +2,33 @@
"name": "Route Commerce",
"short_name": "Route Commerce",
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
"start_url": "/",
"start_url": "/admin",
"scope": "/",
"display": "standalone",
"background_color": "#faf8f5",
"theme_color": "#1a4d2e",
"theme_color": "#166534",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"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"
}
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"categories": ["business", "productivity"],
"shortcuts": [
{
"name": "View Orders",
"short_name": "Orders",
"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" }]
}
{ "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
{ "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
{ "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Admin Dashboard"
},
{
"src": "/screenshots/mobile-storefront.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile Storefront"
}
{ "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
{ "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
],
"related_applications": [],
"prefer_related_applications": false
}
}
+87
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+82 -104
View File
@@ -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 OFFLINE_URL = "/offline";
const SHELL_CACHE = "rc-shell-v3";
const DATA_CACHE = "rc-data-v3";
const OFFLINE_URL = "/offline.html";
const STATIC_ASSETS = [
const SHELL_ASSETS = [
"/",
"/manifest.json",
"/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) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
);
self.skipWaiting();
});
// Activate event - clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
Promise.all([
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
.map((name) => caches.delete(name))
)
),
self.clients.claim(),
])
);
self.clients.claim();
});
// Fetch event - network first, fallback to cache
self.addEventListener("fetch", (event) => {
// Skip non-GET requests
if (event.request.method !== "GET") return;
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
// Skip API requests
if (event.request.url.includes("/api/")) return;
// Navigations → network-first, fall back to cache, fall back to offline page
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
return res;
})
.catch(() =>
caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
)
);
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone response for caching
const responseClone = response.clone();
// Cache successful responses
if (response.status === 200) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Return cached response or offline page
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
// Static assets → cache-first
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.startsWith("/screenshots/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2")
) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
const clone = res.clone();
if (res.status === 200) {
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
}
// Return offline page for navigation requests
if (event.request.mode === "navigate") {
return caches.match(OFFLINE_URL);
}
return new Response("Network error", { status: 408 });
return res;
});
})
);
});
// 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);
}
})
);
});
// Background sync for offline actions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-orders") {
event.waitUntil(syncOrders());
);
return;
}
});
async function syncOrders() {
// Implement order sync logic
console.log("Syncing orders in background...");
}
// 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))
);
});
+44
View File
@@ -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); });
+39
View File
@@ -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);
+66
View File
@@ -28,6 +28,30 @@ export type DashboardSummary = {
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 ─────────────────────────────────────────────────
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 ────────────────────────────────────────────────────────────────────
function formatTimeAgo(dateString: string): string {
+88
View File
@@ -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 };
}
}
+2
View File
@@ -11,6 +11,7 @@ import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
import CommandPalette from "@/components/admin/CommandPalette";
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
// 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>
<RouteAnnouncer />
</div>
<PWAInstallPrompt />
</ToastProviderWrapper>
);
}
+88
View File
@@ -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>
);
}
+196
View File
@@ -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">&ldquo;{order.internal_notes}&rdquo;</p>
</section>
)}
</div>
<StickyActionBar>
<OrderActionButtons orderId={order.id} status={kind} />
</StickyActionBar>
</main>
);
}
+119
View File
@@ -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>
);
}
+375
View File
@@ -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&apos;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>
);
}
+224
View File
@@ -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>
);
}
+320
View File
@@ -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>
);
}
+89
View File
@@ -75,6 +75,87 @@
--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.4810.36) */
--color-info-soft: #dbeafe;
}
/* ─── Type scale (HIG Accessibility-tier) ─── */
:root {
--text-display: 32px;
--text-display--line-height: 1.15;
--text-display--letter-spacing: -0.01em;
--text-h1: 24px;
--text-h1--line-height: 1.2;
--text-h2: 19px;
--text-h2--line-height: 1.3;
--text-body: 18px;
--text-body--line-height: 1.45;
--text-label: 15px;
--text-label--line-height: 1.3;
--text-label--letter-spacing: 0.02em;
--text-mono: 14px;
--text-mono--line-height: 1.4;
--text-meta: 13px;
--text-meta--line-height: 1.4;
}
/* ─── Spacing (8pt grid) ─── */
:root {
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
}
/* ─── Motion ─── */
:root {
--ease-apple: cubic-bezier(0.32, 0.72, 0, 1);
--duration-fast: 150ms;
--duration-base: 200ms;
--duration-slow: 320ms;
}
/* ─── Base ─────────────────────────────────────────────────────── */
*, *::before, *::after {
@@ -213,6 +294,14 @@ select:-webkit-autofill:focus {
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. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
* but flattens everything that fights vestibular comfort. This block wipes:
+21 -1
View File
@@ -30,7 +30,8 @@ const fragmentMono = Fragment_Mono({
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#0a0a0a",
themeColor: "#166534",
viewportFit: "cover",
};
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.",
keywords: ["wholesale produce", "farm fresh", "B2B e-commerce", "produce distribution", "pickup stops", "fresh produce"],
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: {
title: "Route Commerce | Fresh Produce Wholesale Platform",
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
+5
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { ThemeProvider } from "next-themes";
import { MotionConfig } from "framer-motion";
import { CartProvider } from "@/context/CartContext";
@@ -8,9 +9,13 @@ import SiteHeader from "@/components/layout/SiteHeader";
import SiteFooter from "@/components/layout/SiteFooter";
import CartToast from "@/components/storefront/CartToast";
import CartRestoredToast from "@/components/cart/CartRestoredToast";
import { registerServiceWorker } from "@/lib/pwa";
export function Providers({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
registerServiceWorker();
}, []);
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
const isLandingPage = pathname === "/" || pathname === "/brands";
const isAuthPage = pathname === "/login" || pathname === "/admin/login";
+55
View File
@@ -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;
+61
View File
@@ -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>;
}
+94
View File
@@ -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;
+125
View File
@@ -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;
+52
View File
@@ -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;
+25
View File
@@ -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;
+128
View File
@@ -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;
+35
View File
@@ -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;
+30
View File
@@ -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;
+188
View File
@@ -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);
});
});
});
});
+36
View File
@@ -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");
});
});
+24
View File
@@ -36,4 +36,28 @@ export function formatTime(date: string | Date | null | undefined): string {
hour: "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);
}
+50
View File
@@ -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;
}
+113
View File
@@ -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);
});
});
+106
View File
@@ -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;
}
+160
View File
@@ -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);
});
});
+106
View File
@@ -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);
}
+21
View File
@@ -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;
}
+39
View File
@@ -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 });
});
+42
View File
@@ -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);
});
+33
View File
@@ -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,
});
});
+37
View File
@@ -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);
});
+39
View File
@@ -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();
});
+7 -1
View File
@@ -14,7 +14,13 @@ export default defineConfig({
},
test: {
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"],
// Supabase REST, Auth.js v5, and Next.js `cookies()` / `headers()` are
// stubbed in each test — keep the timeout generous.