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