Storefront — Collection
Product grid with tag, availability and price facets, plus sorting and a result count.
npx shadcn@latest add @hextor/store-collectionSource
"use client";
import { useMemo, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CollectionFilters,
type CollectionFiltersValue,
} from "@/registry/hextor/components/store/storefront/collection-filters";
import { ProductGrid } from "@/registry/hextor/components/store/storefront/product-grid";
import {
StoreHeader,
type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import {
StoreFooter,
type StoreFooterProps,
} from "@/registry/hextor/components/store/storefront/store-footer";
import { cn } from "@/registry/hextor/lib/utils";
import { priceRange, productAvailable, type Collection, type Money, type Product } from "@/registry/hextor/lib/store";
import {
resolveLabels,
type CollectionLabels,
} from "@/registry/hextor/components/store/storefront/collection-labels";
export type CollectionSortKey = "featured" | "price-asc" | "price-desc" | "newest" | "title-asc";
export interface CollectionPageProps {
collection: Collection;
/** The collection's products, already resolved from `collection.productIds`. */
products: Product[];
/** Every tag present across `products`, for the tag facet. */
availableTags: string[];
priceBounds: { min: Money; max: Money };
header: StoreHeaderProps;
footer: StoreFooterProps;
labels?: Partial<CollectionLabels>;
className?: string;
}
const DEFAULT_FILTERS: CollectionFiltersValue = { availability: "all", tags: [] };
/**
* A collection grid with facet filters, a sort control and a result count —
* all client-side over the `products` prop. This template's job is the UI,
* not a search index: a real storefront would replace the `useMemo` below
* with a server-side filtered query and keep everything else unchanged.
*/
export default function CollectionPage({
collection,
products,
availableTags,
priceBounds,
header,
footer,
labels,
className,
}: CollectionPageProps) {
const copy = resolveLabels(labels);
const [filters, setFilters] = useState<CollectionFiltersValue>(DEFAULT_FILTERS);
const [sort, setSort] = useState<CollectionSortKey>("featured");
const filtered = useMemo(() => {
let list = products.filter((product) => {
if (filters.availability === "in-stock" && !productAvailable(product)) return false;
if (filters.availability === "out-of-stock" && productAvailable(product)) return false;
if (filters.tags.length > 0 && !filters.tags.every((tag) => product.tags.includes(tag))) {
return false;
}
if (filters.maxPrice != null && priceRange(product).min.amount > filters.maxPrice) {
return false;
}
return true;
});
list = [...list].sort((a, b) => {
switch (sort) {
case "price-asc":
return priceRange(a).min.amount - priceRange(b).min.amount;
case "price-desc":
return priceRange(b).min.amount - priceRange(a).min.amount;
case "newest":
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
case "title-asc":
return a.title.localeCompare(b.title);
case "featured":
default:
return 0;
}
});
return list;
}, [products, filters, sort]);
return (
<div className={cn("flex min-h-screen flex-col", className)}>
<StoreHeader {...header} />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-8 sm:px-6">
<div className="flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold">{collection.title}</h1>
{collection.description && (
<p className="max-w-2xl text-sm text-muted-foreground">{collection.description}</p>
)}
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-[220px_1fr]">
<aside className="hidden md:block">
<CollectionFilters
tags={availableTags}
priceBounds={priceBounds}
value={filters}
onChange={setFilters}
/>
</aside>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{copy.resultsCount(filtered.length)}</span>
<Select value={sort} onValueChange={(next) => setSort(next as CollectionSortKey)}>
<SelectTrigger aria-label={copy.sortLabel}>
<SelectValue placeholder={copy.sortLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value="featured">{copy.sortFeatured}</SelectItem>
<SelectItem value="price-asc">{copy.sortPriceAsc}</SelectItem>
<SelectItem value="price-desc">{copy.sortPriceDesc}</SelectItem>
<SelectItem value="newest">{copy.sortNewest}</SelectItem>
<SelectItem value="title-asc">{copy.sortTitleAsc}</SelectItem>
</SelectContent>
</Select>
</div>
<ProductGrid
products={filtered}
emptyTitle={copy.emptyTitle}
emptyDescription={copy.emptyDescription}
/>
</div>
</div>
</main>
<StoreFooter {...footer} />
</div>
);
}
Docs
Filtering runs client-side over the products prop because the template's job is the UI, not a search index. A real storefront replaces the useMemo with a server-filtered query and changes nothing else.
The facet inputs are derived from the catalogue in the route file rather than hand-listed: a tag no product carries is a filter that always returns nothing, and a price bound that misses the catalogue is a slider you cannot move to either end.
Ships with sample data from @hextor/store-fixtures so it renders a real shop the moment it installs — a template you have to wire up before you can look at it is a template nobody evaluates. The route file is the only place that import appears.