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.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add alert badge button card empty skeletonInstall the glue
Install the block with
npx better-supabase add catalog. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the screen
React and shadcn
View in the exampleproduct-catalog-grid.tsx // 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> ); }