HextorUI
block

Storefront — Search

Query, facets and sorting, plus the two states that decide whether someone stays: nothing searched yet, and nothing found.

npx shadcn@latest add @hextor/store-search

Source

"use client";

import { useMemo, useState } from "react";
import { Search as SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  CollectionFilters,
  type CollectionFiltersLabels,
  type CollectionFiltersValue,
} from "@/registry/hextor/components/store/storefront/collection-filters";
import {
  SearchIdleState,
  SearchNoResultsState,
  suggestCorrection,
} from "@/registry/hextor/components/store/storefront/search-empty";
import { SearchResultCard } from "@/registry/hextor/components/store/storefront/search-result-card";
import { ScrollRevealStyle } from "@/registry/hextor/components/store/storefront/scroll-reveal";
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 { money, priceRange, productAvailable, type Collection, type Product } from "@/registry/hextor/lib/store";
import {
  resolveLabels,
  type SearchLabelsOverrides,
} from "@/registry/hextor/components/store/storefront/search-labels";

export type SearchSortKey = "relevance" | "price-asc" | "price-desc" | "newest";

export interface SearchPageProps {
  /** The typed query — controlled, so the page (or a `?q=` sync in the real router) owns it. */
  query: string;
  onQueryChange: (value: string) => void;
  onQuerySubmit?: (value: string) => void;
  /**
   * The full searchable catalogue. Matching, filtering and sorting all run
   * over this prop with `useMemo` — a real storefront swaps this client-side
   * pass for a server-side search query (Shopify's predictive search API,
   * Algolia, etc.) fired on `query`, and nothing else on this page changes:
   * it already renders a filtered `Product[]`, not a query string.
   */
  products: Product[];
  /** Shown in the idle state and, narrowed to a few, as a "browse instead" route out of a no-results dead end. */
  collections: Collection[];
  collectionHrefFor?: (collection: Collection) => string;
  /**
   * Builds the link for a product card. Defaults to `/products/{handle}`,
   * which is right for a store mounted at the root and wrong for one mounted
   * anywhere else — a demo under /demo, a shop under /shop, a locale prefix.
   * Threading it as a prop is what lets the same view serve all of them; the
   * alternative is rewriting hrefs in the DOM after render, which loses to a
   * click that lands before the script does.
   */
  productHrefFor?: (product: Product) => string;

  /** Terms other shoppers search for — static merchandising copy, not derived from `products`. */
  popularSearches?: string[];
  /** This shopper's own past queries, most recent first. */
  recentSearches?: string[];
  onClearRecentSearches?: () => void;
  browseAllHref?: string;
  header: StoreHeaderProps;
  footer: StoreFooterProps;
  labels?: SearchLabelsOverrides;
  collectionFiltersLabels?: Partial<CollectionFiltersLabels>;
  className?: string;
}

const DEFAULT_FILTERS: CollectionFiltersValue = { availability: "all", tags: [] };

/**
 * The search page: a query, a result count, the same facets a collection has
 * and the same sort control — plus the two states that matter more than the
 * happy path. Baymard's finding is that a search dead end is where shoppers
 * leave, so "no query yet" and "no results" are both first-class renders
 * here (`SearchIdleState` / `SearchNoResultsState`), not a blank div either
 * side of the one state that matched something.
 */
export default function SearchView({
  query,
  onQueryChange,
  onQuerySubmit,
  products,
  collections,
  collectionHrefFor,
  productHrefFor,
  popularSearches = [],
  recentSearches = [],
  onClearRecentSearches,
  browseAllHref = "/collections",
  header,
  footer,
  labels,
  collectionFiltersLabels,
  className,
}: SearchPageProps) {
  const copy = resolveLabels(labels);
  const [filters, setFilters] = useState<CollectionFiltersValue>(DEFAULT_FILTERS);
  const [sort, setSort] = useState<SearchSortKey>("relevance");

  const trimmedQuery = query.trim();
  const hasQuery = trimmedQuery.length > 0;
  const isFiltered = filters.availability !== "all" || filters.tags.length > 0 || filters.maxPrice != null;

  // Facet inputs derived from the whole searchable catalogue, same
  // reasoning as `StoreCollectionPage`'s own derivation: a tag or price
  // ceiling that matches nothing in `products` is a control that never does
  // anything. Kept stable across keystrokes on purpose — a facet list that
  // shrinks and grows while a shopper is still typing reads as broken.
  const availableTags = useMemo(() => [...new Set(products.flatMap((p) => p.tags))].sort(), [products]);
  const priceBounds = useMemo(() => {
    const ranges = products.map(priceRange);
    const first = ranges[0]?.min ?? money(0);
    return {
      min: ranges.reduce((a, r) => (r.min.amount < a.amount ? r.min : a), first),
      max: ranges.reduce((a, r) => (r.max.amount > a.amount ? r.max : a), first),
    };
  }, [products]);

  const vocabulary = useMemo(
    () => [...new Set(products.flatMap((p) => [p.title, p.vendor, ...p.tags]))],
    [products],
  );

  const matched = useMemo(() => {
    if (!hasQuery) return [];
    const q = trimmedQuery.toLowerCase();

    let list = products.filter((product) => {
      const haystack = [product.title, product.vendor, ...product.tags].join(" ").toLowerCase();
      return haystack.includes(q);
    });

    list = list.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 "relevance":
        default: {
          // A title hit ranks above a tag/vendor-only match, then the
          // closer-length title first — an approximation of "closest
          // match" without a real ranking index behind it.
          const scoreOf = (p: Product) => (p.title.toLowerCase().includes(q) ? 0 : 1);
          return scoreOf(a) - scoreOf(b) || a.title.length - b.title.length;
        }
      }
    });

    return list;
  }, [products, hasQuery, trimmedQuery, filters, sort]);

  const suggestion = hasQuery && matched.length === 0 ? suggestCorrection(trimmedQuery, vocabulary) : undefined;

  return (
    <div className={cn("flex min-h-screen flex-col", className)}>
      <ScrollRevealStyle />
      <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-4">
          <span aria-hidden="true" className="h-[3px] w-8 rounded-full bg-brand" />
          <h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">{copy.heading}</h1>
          <form
            role="search"
            className="max-w-xl"
            onSubmit={(e) => {
              e.preventDefault();
              onQuerySubmit?.(query);
            }}
          >
            <label className="relative block">
              <span className="sr-only">{copy.placeholder}</span>
              <SearchIcon
                aria-hidden="true"
                className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
              />
              <Input
                type="search"
                value={query}
                onChange={(e) => onQueryChange(e.target.value)}
                placeholder={copy.placeholder}
                className="h-11 pl-10 text-base"
              />
            </label>
          </form>
        </div>

        {!hasQuery ? (
          <SearchIdleState
            popularSearches={popularSearches}
            recentSearches={recentSearches}
            collections={collections}
            collectionHrefFor={collectionHrefFor}
            onTermClick={onQueryChange}
            onClearRecentSearches={onClearRecentSearches}
            labels={copy.idle}
          />
        ) : matched.length === 0 ? (
          <SearchNoResultsState
            query={trimmedQuery}
            suggestion={suggestion}
            onSuggestionClick={onQueryChange}
            hasActiveFilters={isFiltered}
            onClearFilters={() => setFilters(DEFAULT_FILTERS)}
            collections={collections}
            collectionHrefFor={collectionHrefFor}
            browseAllHref={browseAllHref}
            labels={copy.noResults}
          />
        ) : (
          <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}
                labels={collectionFiltersLabels}
              />
            </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(matched.length, trimmedQuery)}</span>
                <Select value={sort} onValueChange={(next) => setSort(next as SearchSortKey)}>
                  <SelectTrigger aria-label={copy.sortLabel}>
                    <SelectValue placeholder={copy.sortLabel} />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="relevance">{copy.sortRelevance}</SelectItem>
                    <SelectItem value="price-asc">{copy.sortPriceAsc}</SelectItem>
                    <SelectItem value="price-desc">{copy.sortPriceDesc}</SelectItem>
                    <SelectItem value="newest">{copy.sortNewest}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
                {matched.map((product) => (
                  <SearchResultCard key={product.id} product={product} query={trimmedQuery} href={productHrefFor?.(product)} className="reveal-up" />
                ))}
              </div>
            </div>
          </div>
        )}
      </main>

      <StoreFooter {...footer} />
    </div>
  );
}

Docs

The results list is the easy part. The idle state offers recent and popular searches and a route into collections rather than a blank page, and the no-results state repeats what was searched, offers a spelling correction, lets you drop a filter, and points at somewhere broader. A bare "0 results" is a dead end, and a dead end is where people leave.

Matching runs client-side over a products prop so the template works without a search backend; swap the useMemo for a server query and nothing else changes. The matched substring is highlighted in each title so the reason a row came back is visible.

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