+
+ Search
+
+
+ {/* Toolbar */}
+
+
+
+
+
+ {totalCount} {totalCount === 1 ? 'Item' : 'Items'}
+
+
+
+
+
+ {sortOpen && (
+ <>
+
setSortOpen(false)}
+ />
+
+ {SORT_OPTIONS.map((option, index) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ {/* Results */}
+
+ {loading ? (
+
+ {Array.from({ length: 10 }).map((_, index) => (
+
+ ))}
+
+ ) : error ? (
+
{error}
+ ) : products.length === 0 ? (
+
+ No products matched{query ? ` “${query}”` : ' your filters'}.
+
+ ) : (
+ <>
+
+ {products.map((product) => (
+
+ ))}
+
+
+ {hasNextPage && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+export default SearchResults;
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
new file mode 100644
index 0000000..a499e02
--- /dev/null
+++ b/components/ui/command.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import React from 'react';
+import { cn } from '@/lib/utils';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { RiSearchLine, RiCloseLine } from '@remixicon/react';
+import { Button } from '@/components/ui/button';
+
+// A command palette in the shadcn shape, implemented on this project's own
+// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
+// caller, which suits async sources like the Storefront API.
+
+function Command({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+interface CommandDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: React.ReactNode;
+ className?: string;
+}
+
+function CommandDialog({
+ open,
+ onOpenChange,
+ children,
+ className,
+}: CommandDialogProps) {
+ // Dialog portals straight into document.body, so hold off until mounted or
+ // prerendering this component's page throws "document is not defined".
+ const [mounted, setMounted] = React.useState(false);
+ React.useEffect(() => setMounted(true), []);
+
+ if (!mounted) return null;
+
+ return (
+
+ );
+}
+
+interface CommandInputProps
+ extends Omit
, 'onChange'> {
+ onValueChange?: (value: string) => void;
+ onClear?: () => void;
+ onClose?: () => void;
+}
+
+function CommandInput({
+ className,
+ value,
+ onValueChange,
+ onClear,
+ onClose,
+ ...props
+}: CommandInputProps) {
+ const hasValue = Boolean(String(value ?? '').length);
+
+ return (
+
+
+ onValueChange?.(event.target.value)}
+ className={cn(
+ 'flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground',
+ className
+ )}
+ {...props}
+ />
+ {hasValue && onClear && (
+
+ )}
+ {onClose && (
+
+ )}
+
+ );
+}
+
+function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+interface CommandGroupProps extends React.ComponentProps<'div'> {
+ heading?: string;
+}
+
+function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) {
+ return (
+
+ {heading && (
+
+ {heading}
+
+ )}
+ {children}
+
+ );
+}
+
+function CommandItem({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandSeparator,
+};
diff --git a/graphql/search.js b/graphql/search.js
new file mode 100644
index 0000000..7ec0dab
--- /dev/null
+++ b/graphql/search.js
@@ -0,0 +1,79 @@
+import { ProductFragment } from '@/graphql/products';
+
+// Storefront search over products. `productFilters` accepts the raw `input`
+// values returned in `productFilters[].values[].input`, so facets round-trip
+// without the client needing to know each filter's shape.
+export const SEARCH_PRODUCTS_QUERY = `
+ ${ProductFragment}
+ query SearchProducts(
+ $query: String!
+ $first: Int!
+ $after: String
+ $sortKey: SearchSortKeys
+ $reverse: Boolean
+ $productFilters: [ProductFilter!]
+ ) {
+ search(
+ query: $query
+ first: $first
+ after: $after
+ types: PRODUCT
+ sortKey: $sortKey
+ reverse: $reverse
+ productFilters: $productFilters
+ ) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ productFilters {
+ id
+ label
+ type
+ values {
+ id
+ label
+ count
+ input
+ }
+ }
+ edges {
+ node {
+ ... on Product {
+ ...ProductFragment
+ }
+ }
+ }
+ }
+ }
+`;
+
+// Lightweight variant for the autocomplete dropdown — just enough to render a
+// row, so the dialog stays responsive while typing.
+export const SEARCH_SUGGESTIONS_QUERY = `
+ query SearchSuggestions($query: String!, $first: Int!) {
+ search(query: $query, first: $first, types: PRODUCT) {
+ totalCount
+ edges {
+ node {
+ ... on Product {
+ id
+ title
+ handle
+ featuredImage {
+ url
+ altText
+ }
+ priceRange {
+ minVariantPrice {
+ amount
+ currencyCode
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
diff --git a/hooks/use-shopify-search.ts b/hooks/use-shopify-search.ts
new file mode 100644
index 0000000..1a57c89
--- /dev/null
+++ b/hooks/use-shopify-search.ts
@@ -0,0 +1,120 @@
+'use client';
+
+import { shopifyFetch } from '@/services/shopify/client';
+import {
+ SEARCH_PRODUCTS_QUERY,
+ SEARCH_SUGGESTIONS_QUERY,
+} from '@/graphql/search';
+import type { Product } from '@/hooks/use-shopify-products';
+
+export type SearchSortKey = 'RELEVANCE' | 'PRICE';
+
+export interface SearchFilterValue {
+ id: string;
+ label: string;
+ count: number;
+ /** JSON string accepted back as a `ProductFilter` input. */
+ input: string;
+}
+
+export interface SearchFilter {
+ id: string;
+ label: string;
+ type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
+ values: SearchFilterValue[];
+}
+
+export interface SearchProductsResult {
+ products: Product[];
+ totalCount: number;
+ filters: SearchFilter[];
+ hasNextPage: boolean;
+ endCursor: string | null;
+}
+
+export interface SearchSuggestion {
+ id: string;
+ title: string;
+ handle: string;
+ featuredImage?: {
+ url: string;
+ altText?: string;
+ } | null;
+ priceRange: {
+ minVariantPrice: {
+ amount: string;
+ currencyCode: string;
+ };
+ };
+}
+
+interface SearchProductsOptions {
+ query: string;
+ first?: number;
+ after?: string | null;
+ sortKey?: SearchSortKey;
+ reverse?: boolean;
+ /** Raw `input` strings from the facets, parsed back into filter objects. */
+ filterInputs?: string[];
+}
+
+function parseFilterInputs(inputs: string[]): unknown[] {
+ return inputs.flatMap((input) => {
+ try {
+ return [JSON.parse(input)];
+ } catch {
+ console.warn('Ignoring malformed product filter input:', input);
+ return [];
+ }
+ });
+}
+
+export async function searchProducts({
+ query,
+ first = 24,
+ after = null,
+ sortKey = 'RELEVANCE',
+ reverse = false,
+ filterInputs = [],
+}: SearchProductsOptions): Promise {
+ const response = await shopifyFetch({
+ query: SEARCH_PRODUCTS_QUERY,
+ variables: {
+ query,
+ first,
+ after,
+ sortKey,
+ reverse,
+ productFilters: filterInputs.length
+ ? parseFilterInputs(filterInputs)
+ : null,
+ },
+ });
+
+ const search = response.data.search;
+
+ return {
+ products: search.edges.map((edge: { node: Product }) => edge.node),
+ totalCount: search.totalCount ?? 0,
+ filters: search.productFilters ?? [],
+ hasNextPage: Boolean(search.pageInfo?.hasNextPage),
+ endCursor: search.pageInfo?.endCursor ?? null,
+ };
+}
+
+export async function searchSuggestions(
+ query: string,
+ first = 3
+): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
+ const response = await shopifyFetch({
+ query: SEARCH_SUGGESTIONS_QUERY,
+ variables: { query, first },
+ });
+
+ const search = response.data.search;
+
+ return {
+ products: search.edges.map((edge: { node: SearchSuggestion }) => edge.node),
+ totalCount: search.totalCount ?? 0,
+ };
+}