HextorUI
block

Storefront — Collection

Product grid with tag, availability and price facets, plus sorting and a result count.

npx shadcn@latest add @hextor/store-collection

Source

"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.

Dependencies

@hextor/store-ui@hextor/store-fixtures