feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust)
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import PageHeader from "@/components/admin/PageHeader";
|
||||||
|
import { CardList, CardListItem } from "@/components/admin/CardList";
|
||||||
|
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||||
|
import EmptyState from "@/components/admin/EmptyState";
|
||||||
|
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||||
|
import ProductsFilterChips from "@/components/admin/products/ProductsFilterChips";
|
||||||
|
import StockAdjustButton from "@/components/admin/products/StockAdjustButton";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row shape for the v2 products list. Mirrors the columns we actually
|
||||||
|
* select from the `products` table — the plan's speculative shape used
|
||||||
|
* `stock` / `image_url` / `price_cents` which is close, but the real
|
||||||
|
* stock column is `inventory` (not `stock`), `image_url` and
|
||||||
|
* `price_cents` are correct as-is, and there is no `hidden` field
|
||||||
|
* (visibility is the boolean `active`).
|
||||||
|
*/
|
||||||
|
interface DbProductRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price_cents: number;
|
||||||
|
inventory: number;
|
||||||
|
image_url: string | null;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an inventory count onto the StatusPill vocabulary. Matches the
|
||||||
|
* thresholds the v1 admin Products page uses (out = 0, low < 10, else in
|
||||||
|
* stock). The `kind` doubles as a CSS-friendly token; the `label` is the
|
||||||
|
* user-visible string (includes the count so the user can read it at
|
||||||
|
* a glance in the field).
|
||||||
|
*/
|
||||||
|
function stockStatus(inventory: number): { kind: StatusKind; label: string } {
|
||||||
|
if (inventory === 0) return { kind: "out", label: "Out of stock" };
|
||||||
|
if (inventory < 10) return { kind: "low", label: `⚠ ${inventory} left` };
|
||||||
|
return { kind: "in-stock", label: `${inventory} in stock` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inventory/active filter clause for a given `?filter=` value.
|
||||||
|
*
|
||||||
|
* - "all" → no filter
|
||||||
|
* - "in-stock" → inventory >= 10
|
||||||
|
* - "low" → 0 < inventory < 10
|
||||||
|
* - "out" → inventory = 0
|
||||||
|
* - "hidden" → active = false (no separate `hidden` column in the
|
||||||
|
* schema — "hidden" maps to the legacy "inactive" state)
|
||||||
|
* - unknown → same as "all" (defensive: don't 500 on a bad URL)
|
||||||
|
*/
|
||||||
|
function filterClause(filter: string | undefined): { sql: string; params: unknown[] } {
|
||||||
|
switch (filter) {
|
||||||
|
case "in-stock":
|
||||||
|
return { sql: " AND inventory >= 10", params: [] };
|
||||||
|
case "low":
|
||||||
|
return { sql: " AND inventory > 0 AND inventory < 10", params: [] };
|
||||||
|
case "out":
|
||||||
|
return { sql: " AND inventory = 0", params: [] };
|
||||||
|
case "hidden":
|
||||||
|
return { sql: " AND active = false", params: [] };
|
||||||
|
case "all":
|
||||||
|
default:
|
||||||
|
return { sql: "", params: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductsV2Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ filter?: string }>;
|
||||||
|
}) {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
const params = await searchParams;
|
||||||
|
const filter = params.filter;
|
||||||
|
const { sql: filterSql, params: filterParams } = filterClause(filter);
|
||||||
|
|
||||||
|
// Mirror the v2 stops page's brand-scoping pattern: explicit active
|
||||||
|
// brand > platform_admin "all brands" > multi-brand membership list.
|
||||||
|
let products: DbProductRow[] = [];
|
||||||
|
let queryError: string | null = null;
|
||||||
|
try {
|
||||||
|
const selectFields = `
|
||||||
|
id, name, price_cents, inventory, image_url, active, brand_id
|
||||||
|
`;
|
||||||
|
const orderBy = `name ASC`;
|
||||||
|
|
||||||
|
if (activeBrandId) {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = $1 ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[activeBrandId, ...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
} else if (adminUser.role === "platform_admin") {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE 1=1 ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
} else {
|
||||||
|
const brandIds = adminUser.brand_ids ?? [];
|
||||||
|
if (brandIds.length === 0) {
|
||||||
|
products = [];
|
||||||
|
} else {
|
||||||
|
const result = await pool.query<DbProductRow>(
|
||||||
|
`SELECT ${selectFields}
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = ANY($1::uuid[]) ${filterSql}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT 200`,
|
||||||
|
[brandIds, ...filterParams],
|
||||||
|
);
|
||||||
|
products = result.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
queryError = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[admin/v2/products] query error:", queryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryError) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Products" />
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<div className="px-4 pb-24">
|
||||||
|
<EmptyState
|
||||||
|
title="Couldn't load products"
|
||||||
|
description={queryError}
|
||||||
|
icon={<span aria-hidden="true">⚠️</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader title="Products" />
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<EmptyState
|
||||||
|
title="No products"
|
||||||
|
description="Add your first product to start selling."
|
||||||
|
icon={<span aria-hidden="true">🥬</span>}
|
||||||
|
/>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<PageHeader
|
||||||
|
title="Products"
|
||||||
|
subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`}
|
||||||
|
/>
|
||||||
|
<ProductsFilterChips active={filter ?? "all"} />
|
||||||
|
<PullToRefresh>
|
||||||
|
<CardList ariaLabel="Products">
|
||||||
|
{products.map((product) => {
|
||||||
|
const stock = stockStatus(product.inventory);
|
||||||
|
// price_cents → display dollars, two decimals. The schema
|
||||||
|
// stores integer cents, so the legacy `price` field (in
|
||||||
|
// dollars) would be `price_cents / 100`.
|
||||||
|
const priceDollars = (product.price_cents / 100).toFixed(2);
|
||||||
|
return (
|
||||||
|
<CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{product.image_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- product
|
||||||
|
// images may live on a third-party CDN; Next/Image
|
||||||
|
// would require us to whitelist the host. A plain
|
||||||
|
// <img> is fine here since this is a mobile-first
|
||||||
|
// admin page, not a public storefront.
|
||||||
|
<img
|
||||||
|
src={product.image_url}
|
||||||
|
alt=""
|
||||||
|
className="w-16 h-16 rounded-2xl object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded-2xl shrink-0 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--color-surface-3)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
🥬
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{product.name}</div>
|
||||||
|
<StockAdjustButton productId={product.id} currentStock={product.inventory} />
|
||||||
|
</div>
|
||||||
|
<div className="text-h1 font-display mt-1" style={{ fontWeight: 600 }}>${priceDollars}</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<StatusPill status={stock.kind} label={stock.label} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardList>
|
||||||
|
</PullToRefresh>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
Reference in New Issue
Block a user