"use client"; import { getFilterRemovalUrl, getSortByValue, serializeCollectionParams, type AvailableFilter, type ProductFilter, } from "@shopify/hydrogen"; import { CollectionProvider, useCollection, useCollectionForm } from "@shopify/hydrogen/react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo } from "react"; import { Breadcrumbs } from "@/components/Breadcrumbs"; import { ProductCard, type ProductCardData } from "@/components/ProductCard"; import { AnalyticsEvent, getAnalytics } from "@/lib/analytics"; import { content } from "@/lib/content"; import { FilterGroup } from "@/lib/filters"; /** * Shared collection/search browser (`hydrogen-collection-browser` / * `references/nextjs.md`). The server page fetches the product + filter * snapshot via `staticStorefrontClient` and * passes it here. This client component owns the `CollectionProvider`, * `useCollection`/`useCollectionForm`, URL sync via `useRouter`/`useSearchParams`, * and the filter/sort/grid UI. * * `onChange` → `router.replace(href, { scroll: false })` + `router.refresh()` so * the RSC payload catches up when the client URL is ahead (skill-mandated). * Browse forms carry `method="get"` + explicit `action` (feedback Round 4 #4) * so they submit without JS (F4). */ export const COLLECTION_SORT_OPTIONS = [ { label: "Featured", value: getSortByValue("COLLECTION_DEFAULT", false) }, { label: "Best selling", value: getSortByValue("BEST_SELLING", false) }, { label: "Alphabetically, A-Z", value: getSortByValue("TITLE", false) }, { label: "Alphabetically, Z-A", value: getSortByValue("TITLE", true) }, { label: "Price, low to high", value: getSortByValue("PRICE", false) }, { label: "Price, high to low", value: getSortByValue("PRICE", true) }, { label: "Date, new to old", value: getSortByValue("CREATED", true) }, ]; export const SEARCH_SORT_OPTIONS = [ { label: "Relevance", value: getSortByValue("RELEVANCE", false) }, { label: "Price, low to high", value: getSortByValue("PRICE", false) }, { label: "Price, high to low", value: getSortByValue("PRICE", true) }, ]; type PageInfo = { hasNextPage: boolean; endCursor?: string | null }; export type CollectionBrowserProps = | { mode: "collection"; handle: string; collection: { id: string; handle: string; title: string; description?: string | null; descriptionHtml?: string | null; }; products: ProductCardData[]; availableFilters: AvailableFilter[]; pageInfo: PageInfo; dataSearch: string; } | { mode: "search"; term: string; products: ProductCardData[]; availableFilters: AvailableFilter[]; pageInfo: PageInfo; totalCount: number; dataSearch: string; }; export function CollectionBrowser(props: CollectionBrowserProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const urlSearch = searchParams.toString(); const providerHandle = props.mode === "collection" ? props.handle : `search:${props.term}`; return ( { const href = `${pathname}${search}`; if (urlSearch) router.replace(href, { scroll: false }); else router.push(href, { scroll: false }); router.refresh(); }} > {props.mode === "collection" ? ( ) : ( )} {props.mode === "collection" ? : } ); } function CollectionViewedTracker({ id, handle }: { id: string; handle: string }) { useEffect(() => { const analytics = getAnalytics(); if (!analytics) return; analytics.publish(AnalyticsEvent.COLLECTION_VIEWED, { collection: { id, handle }, }); }, [id, handle]); return null; } function SearchViewedTracker({ term, totalCount }: { term: string; totalCount: number }) { useEffect(() => { if (!term) return; const analytics = getAnalytics(); if (!analytics) return; analytics.publish(AnalyticsEvent.SEARCH_VIEWED, { searchTerm: term, searchResults: { totalCount }, }); }, [term, totalCount]); return null; } type CollectionPageProps = Extract; function CollectionPage(props: CollectionPageProps) { const { collection, products, availableFilters, pageInfo } = props; const state = useCollection(); const { formProps } = useCollectionForm(); const router = useRouter(); // Reset key for the uncontrolled filter subtree (checkboxes + price inputs). // Keyed by the serialized filter state (NOT the URL) so the subtree remounts // *after* the reconciler settles `state.filters` — clearing `defaultChecked` / // `defaultValue` when an external navigation (chip removal, clear-all) empties // the filters. Keying by the live URL is racy: the URL clears before // `state.filters` settles, so the remount would bake in the stale checked // state (hydrogen-collection-browser/references/nextjs.md reset-key guidance). const filterSubtreeKey = serializeCollectionParams({ filters: state.filters, sortKey: undefined, reverse: false, }).toString(); const isLoading = state.status === "loading"; const collectionPath = `/collections/${collection.handle}`; // `` navigations (active-filter chips, clear-all, load-more) update the // URL but, under Cache Components / PPR, the RSC payload doesn't re-fetch on a // same-segment `searchParams` change unless we explicitly refresh. `onChange` // (form-driven) already calls `router.refresh()`; this wires the same refresh // to the `` clicks so the server products/filters catch up to the URL // (hydrogen-collection-browser/references/nextjs.md). const onNavigate = () => router.refresh(); const showingCount = content.collection.showingCount .replace("{{ shown }}", String(products.length)) .replace("{{ total }}", String(products.length)); return (

{collection.title}

{collection.descriptionHtml ? (
) : collection.description ? (

{collection.description}

) : null}

{showingCount}

{products.length === 0 ? (

{content.collection.noProducts}

) : (
    {products.map((product, index) => (
  • ))}
)} {/* No-JS submit so the GET filter/sort form is submittable without JS (F4). */}
); } type SearchPageProps = Extract; function SearchPage(props: SearchPageProps) { const { term, products, availableFilters, pageInfo, totalCount } = props; const state = useCollection(); const { formProps } = useCollectionForm(); const router = useRouter(); const onNavigate = () => router.refresh(); // Reset key for the uncontrolled filter subtree — see CollectionPage for rationale. const filterSubtreeKey = serializeCollectionParams({ filters: state.filters, sortKey: undefined, reverse: false, }).toString(); const isLoading = state.status === "loading"; return (

{content.search.title}

{/* Search header form — real GET /search so it works without JS (F4). */}
{term ? ( {content.search.clear} ) : null}
{!term ? null : products.length === 0 ? (

{content.search.noResults.replace("{{ terms }}", `\u201c${term}\u201d`)}

{content.search.noResultsSuggestion}

) : (

{content.search.resultsFor .replace("{{ count }}", String(totalCount)) .replace("{{ terms }}", `\u201c${term}\u201d`)}

    {products.map((product, index) => (
  • ))}
{pageInfo.hasNextPage ? (
{content.search.loadMore}
) : null}
)}
); } type SortOption = { label: string; value: string }; function SortSelect({ isLoading, options }: { isLoading: boolean; options: SortOption[] }) { const state = useCollection(); const currentSort = useMemo(() => { return serializeCollectionParams(state).toString(); }, [state]); return ( ); } function FilterSidebar({ availableFilters, activeFilters, disabled, }: { availableFilters: AvailableFilter[]; activeFilters: ProductFilter[]; disabled: boolean; }) { if (availableFilters.length === 0) return null; return ( ); } function ActiveFilterChips({ activeFilters, collectionPath, onNavigate, }: { activeFilters: ProductFilter[]; collectionPath: string; onNavigate: () => void; }) { if (activeFilters.length === 0) return null; return (
    {activeFilters.map((filter, index) => { const currentParams = serializeCollectionParams({ filters: activeFilters, sortKey: undefined, reverse: false, }); const removal = getFilterRemovalUrl(currentParams, filter); const href = removal === "?" ? collectionPath : `${collectionPath}${removal}`; return (
  • {describeFilter(filter)}
  • ); })}
  • {content.collection.clearAll}
); } function describeFilter(filter: ProductFilter): string { if (filter.available !== undefined) return filter.available ? "In stock" : "Out of stock"; if (filter.productType) return filter.productType; if (filter.productVendor) return filter.productVendor; if (filter.tag) return filter.tag; if (filter.variantOption) { const option = filter.variantOption; return option.value ?? option.name ?? "Variant"; } if (filter.price) { const price = filter.price; const hasMin = price.min != null && Number(price.min) > 0; const hasMax = price.max != null; if (hasMin && hasMax) return `${price.min} ${content.collection.priceTo} ${price.max}`; if (hasMax) return `Up to ${price.max}`; if (hasMin) return `From ${price.min}`; return "Price"; } return "Filter"; } function LoadMore({ pageInfo, collectionPath, onNavigate, }: { pageInfo: PageInfo; collectionPath: string; onNavigate: () => void; }) { if (!pageInfo.hasNextPage) return null; const cursor = pageInfo.endCursor ?? ""; const href = `${collectionPath}?after=${encodeURIComponent(cursor)}`; return (
{content.collection.loadMore}
); }