diff --git a/src/app/admin/v2/products/page.tsx b/src/app/admin/v2/products/page.tsx new file mode 100644 index 0000000..d56b32a --- /dev/null +++ b/src/app/admin/v2/products/page.tsx @@ -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( + `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( + `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( + `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 ( +
+ + +
+
+
+ ); + } + + if (products.length === 0) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ + + + + {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 ( + +
+ {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 + // is fine here since this is a mobile-first + // admin page, not a public storefront. + + ) : ( + + )} +
+
+
{product.name}
+ +
+
${priceDollars}
+
+ +
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/admin/products/ProductsFilterChips.tsx b/src/components/admin/products/ProductsFilterChips.tsx new file mode 100644 index 0000000..d81d945 --- /dev/null +++ b/src/components/admin/products/ProductsFilterChips.tsx @@ -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 ( +
+ {FILTERS.map((f) => { + const isActive = active === f.value; + return ( + + ); + })} +
+ ); +} + +export default ProductsFilterChips; diff --git a/src/components/admin/products/StockAdjustButton.tsx b/src/components/admin/products/StockAdjustButton.tsx new file mode 100644 index 0000000..f4224a7 --- /dev/null +++ b/src/components/admin/products/StockAdjustButton.tsx @@ -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 | 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 ( + <> + + {open && ( +
+
+
+

Adjust stock

+
+ +
{stock}
+ +
+ +
+
+ )} + + ); +} + +export default StockAdjustButton;