RegistryUI recipes
Product catalog grid
Products in a grid with prices, variants and a filter by collection.
It loads its data on the server through the block's client; the catalog block must be installed (npx better-supabase add catalog). Its page on bettersupabase.com is /recipes/product-catalog-grid.
Web, with shadcn components:
npx shadcn add alert badge button card empty skeleton// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
"use server";
refresh();
}
/** Render inside `<Suspense>`: the organization's products as cards. */
export async function ProductCatalogGrid() {
const session = await getSession();
if (!can(session, "catalog.read")) {
forbidden();
}
const [products, t, locale] = await Promise.all([
getProducts(),
getExtracted("shop"),
getLocale(),
]);
if (products.kind === "no-organization") {
return null;
}
const canManage = can(session, "catalog.manage");
const status = (product: Product) => {
switch (product.status) {
case "active": {
return <Badge variant="success-light">{t("Active")}</Badge>;
}
case "draft": {
return <Badge variant="secondary">{t("Draft")}</Badge>;
}
case "archived": {
return <Badge variant="warning-light">{t("Archived")}</Badge>;
}
default: {
const unknown: never = product.status;
return unknown;
}
}
};
return (
<div data-recipe="product-catalog-grid" className="flex flex-col gap-4">
{products.kind === "error" ? (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{t("The products didn't load")}</AlertTitle>
<AlertDescription>{t("Try again in a moment.")}</AlertDescription>
<AlertAction>
<form action={retry}>
<Button size="sm" variant="outline" type="submit">
{t("Retry")}
</Button>
</form>
</AlertAction>
</Alert>
) : products.data.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<PackageIcon />
</EmptyMedia>
<EmptyTitle>{t("No products yet")}</EmptyTitle>
<EmptyDescription>
{t("Add a product with a price to start taking orders.")}
</EmptyDescription>
</EmptyHeader>
{canManage ? (
<EmptyContent>
<CreateProductDialog />
</EmptyContent>
) : null}
</Empty>
) : (
<>
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{t("{count, plural, one {# product} other {# products}}", {
count: products.data.length,
})}
</p>
<div className="flex items-center gap-2">
{can(session, "promotions.manage") ? (
<CreatePromotionCodeDialog />
) : null}
{canManage ? <CreateProductDialog variant="outline" /> : null}
</div>
</div>
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{products.data.map((product) => (
<li key={product.id}>
<Card>
<CardHeader>
<CardTitle>{product.title}</CardTitle>
<CardDescription className="font-mono">
{product.sku}
</CardDescription>
<CardAction>{status(product)}</CardAction>
</CardHeader>
<CardContent className="flex items-end justify-between gap-2">
<span className="text-sm font-semibold tabular-nums">
{product.price
? formatMoney(product.price, locale)
: t("No price")}
</span>
<span className="text-xs text-muted-foreground">
{product.available === null
? t("Not stocked")
: t("{count, number} available", {
count: product.available,
})}
</span>
</CardContent>
</Card>
</li>
))}
</ul>
</>
)}
</div>
);
}Last updated on