From d629cfe3d55020b709a17bafc5f166df69a924c5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 14:42:49 -0600 Subject: [PATCH] feat(admin): add v2 layout with AdminShell and offline dispatcher --- src/actions/offline-dispatcher.ts | 88 +++++++++++++++++++++++++++++++ src/app/admin/v2/layout.tsx | 88 +++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/actions/offline-dispatcher.ts create mode 100644 src/app/admin/v2/layout.tsx diff --git a/src/actions/offline-dispatcher.ts b/src/actions/offline-dispatcher.ts new file mode 100644 index 0000000..2e989bd --- /dev/null +++ b/src/actions/offline-dispatcher.ts @@ -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 { + // 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 }; + } +} diff --git a/src/app/admin/v2/layout.tsx b/src/app/admin/v2/layout.tsx new file mode 100644 index 0000000..6936e42 --- /dev/null +++ b/src/app/admin/v2/layout.tsx @@ -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 ( + + {children} + + + ); +} + +export default async function AdminV2Layout({ children }: { children: React.ReactNode }) { + let adminUser = null; + try { + adminUser = await getAdminUser(); + } catch { + return ( + + + + ); + } + + if (!adminUser) { + return ( + + + + ); + } + + 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> = []; + try { + brands = await listBrandsForAdmin(); + } catch (err) { + console.error("[admin/v2/layout] listBrandsForAdmin failed:", err); + } + + let enabledAddons: Record = {}; + if (activeBrandId) { + try { + enabledAddons = await getEnabledAddons(activeBrandId); + } catch (err) { + console.error("[admin/v2/layout] getEnabledAddons failed:", err); + } + } + + return ( + + + {children} + + + + ); +}