import { useParams, useFetcher, Link, Form, type FormProps, } from '@remix-run/react'; import {Image, Money, Pagination} from '@shopify/hydrogen'; import React, {useRef, useEffect} from 'react'; import {useFetchers} from '@remix-run/react'; import type { PredictiveProductFragment, PredictiveCollectionFragment, PredictiveArticleFragment, SearchQuery, } from 'storefrontapi.generated'; type PredicticeSearchResultItemImage = | PredictiveCollectionFragment['image'] | PredictiveArticleFragment['image'] | PredictiveProductFragment['variants']['nodes'][0]['image']; type PredictiveSearchResultItemPrice = | PredictiveProductFragment['variants']['nodes'][0]['price']; export type NormalizedPredictiveSearchResultItem = { __typename: string | undefined; handle: string; id: string; image?: PredicticeSearchResultItemImage; price?: PredictiveSearchResultItemPrice; styledTitle?: string; title: string; url: string; }; export type NormalizedPredictiveSearchResults = Array< | {type: 'queries'; items: Array} | {type: 'products'; items: Array} | {type: 'collections'; items: Array} | {type: 'pages'; items: Array} | {type: 'articles'; items: Array} >; export type NormalizedPredictiveSearch = { results: NormalizedPredictiveSearchResults; totalResults: number; }; type FetchSearchResultsReturn = { searchResults: { results: SearchQuery | null; totalResults: number; }; searchTerm: string; }; export const NO_PREDICTIVE_SEARCH_RESULTS: NormalizedPredictiveSearchResults = [ {type: 'queries', items: []}, {type: 'products', items: []}, {type: 'collections', items: []}, {type: 'pages', items: []}, {type: 'articles', items: []}, ]; export function SearchForm({searchTerm}: {searchTerm: string}) { const inputRef = useRef(null); // focus the input when cmd+k is pressed useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'k' && event.metaKey) { event.preventDefault(); inputRef.current?.focus(); } if (event.key === 'Escape') { inputRef.current?.blur(); } } document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, []); return (
 
); } export function SearchResults({ results, }: Pick) { if (!results) { return null; } const keys = Object.keys(results) as Array; return (
{results && keys.map((type) => { const resourceResults = results[type]; if (resourceResults.nodes[0]?.__typename === 'Page') { const pageResults = resourceResults as SearchQuery['pages']; return resourceResults.nodes.length ? ( ) : null; } if (resourceResults.nodes[0]?.__typename === 'Product') { const productResults = resourceResults as SearchQuery['products']; return resourceResults.nodes.length ? ( ) : null; } if (resourceResults.nodes[0]?.__typename === 'Article') { const articleResults = resourceResults as SearchQuery['articles']; return resourceResults.nodes.length ? ( ) : null; } return null; })}
); } function SearchResultsProductsGrid({products}: Pick) { return (

Products

{({nodes, isLoading, NextLink, PreviousLink}) => { const itemsMarkup = nodes.map((product) => (
{product.title}
)); return (
{isLoading ? 'Loading...' : ↑ Load previous}
{itemsMarkup}
{isLoading ? 'Loading...' : Load more ↓}
); }}

); } function SearchResultPageGrid({pages}: Pick) { return (

Pages

{pages?.nodes?.map((page) => (
{page.title}
))}

); } function SearchResultArticleGrid({articles}: Pick) { return (

Articles

{articles?.nodes?.map((article) => (
{article.title}
))}

); } export function NoSearchResults() { return

No results, try a different search.

; } type ChildrenRenderProps = { fetchResults: (event: React.ChangeEvent) => void; fetcher: ReturnType>; inputRef: React.MutableRefObject; }; type SearchFromProps = { action?: FormProps['action']; method?: FormProps['method']; className?: string; children: (passedProps: ChildrenRenderProps) => React.ReactNode; [key: string]: unknown; }; /** * Search form component that posts search requests to the `/search` route **/ export function PredictiveSearchForm({ action, children, className = 'predictive-search-form', method = 'POST', ...props }: SearchFromProps) { const params = useParams(); const fetcher = useFetcher(); const inputRef = useRef(null); function fetchResults(event: React.ChangeEvent) { const searchAction = action ?? '/api/predictive-search'; const localizedAction = params.locale ? `/${params.locale}${searchAction}` : searchAction; const newSearchTerm = event.target.value || ''; fetcher.submit( {q: newSearchTerm, limit: '6'}, {method, action: localizedAction}, ); } // ensure the passed input has a type of search, because SearchResults // will select the element based on the input useEffect(() => { inputRef?.current?.setAttribute('type', 'search'); }, []); return ( { event.preventDefault(); event.stopPropagation(); if (!inputRef?.current || inputRef.current.value === '') { return; } inputRef.current.blur(); }} > {children({fetchResults, inputRef, fetcher})} ); } export function PredictiveSearchResults() { const {results, totalResults, searchInputRef, searchTerm} = usePredictiveSearch(); function goToSearchResult(event: React.MouseEvent) { if (!searchInputRef.current) return; searchInputRef.current.blur(); searchInputRef.current.value = ''; // close the aside window.location.href = event.currentTarget.href; } if (!totalResults) { return ; } return (
{results.map(({type, items}) => ( ))}
{/* view all results /search?q=term */} {searchTerm.current && (

View all results for {searchTerm.current}   →

)}
); } function NoPredictiveSearchResults({ searchTerm, }: { searchTerm: React.MutableRefObject; }) { if (!searchTerm.current) { return null; } return (

No results found for {searchTerm.current}

); } type SearchResultTypeProps = { goToSearchResult: (event: React.MouseEvent) => void; items: NormalizedPredictiveSearchResultItem[]; searchTerm: UseSearchReturn['searchTerm']; type: NormalizedPredictiveSearchResults[number]['type']; }; function PredictiveSearchResult({ goToSearchResult, items, searchTerm, type, }: SearchResultTypeProps) { const isSuggestions = type === 'queries'; const categoryUrl = `/search?q=${ searchTerm.current }&type=${pluralToSingularSearchType(type)}`; return (
{isSuggestions ? 'Suggestions' : type}
    {items.map((item: NormalizedPredictiveSearchResultItem) => ( ))}
); } type SearchResultItemProps = Pick & { item: NormalizedPredictiveSearchResultItem; }; function SearchResultItem({goToSearchResult, item}: SearchResultItemProps) { return (
  • {item.image?.url && ( {item.image.altText )}
    {item.styledTitle ? (
    ) : ( {item.title} )} {item?.price && ( )}
  • ); } type UseSearchReturn = NormalizedPredictiveSearch & { searchInputRef: React.MutableRefObject; searchTerm: React.MutableRefObject; }; function usePredictiveSearch(): UseSearchReturn { const fetchers = useFetchers(); const searchTerm = useRef(''); const searchInputRef = useRef(null); const searchFetcher = fetchers.find((fetcher) => fetcher.data?.searchResults); if (searchFetcher?.state === 'loading') { searchTerm.current = (searchFetcher.formData?.get('q') || '') as string; } const search = (searchFetcher?.data?.searchResults || { results: NO_PREDICTIVE_SEARCH_RESULTS, totalResults: 0, }) as NormalizedPredictiveSearch; // capture the search input element as a ref useEffect(() => { if (searchInputRef.current) return; searchInputRef.current = document.querySelector('input[type="search"]'); }, []); return {...search, searchInputRef, searchTerm}; } /** * Converts a plural search type to a singular search type * @param type - The plural search type * @returns The singular search type * * @example * ```ts * pluralToSingularSearchType('articles') // => 'ARTICLE' * pluralToSingularSearchType(['articles', 'products']) // => 'ARTICLE,PRODUCT' * ``` */ function pluralToSingularSearchType( type: | NormalizedPredictiveSearchResults[number]['type'] | Array, ) { const plural = { articles: 'ARTICLE', collections: 'COLLECTION', pages: 'PAGE', products: 'PRODUCT', queries: 'QUERY', }; if (typeof type === 'string') { return plural[type]; } return type.map((t) => plural[t]).join(','); }