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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user