"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"; // ANALYTICS DISABLED: `useEffect` was only used by the viewed-trackers below. import { useMemo } from "react"; import { Breadcrumbs } from "@/components/Breadcrumbs"; import { ProductCard, type ProductCardData } from "@/components/ProductCard"; // ANALYTICS DISABLED (temporarily commented out): // 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(); }} > {/* ANALYTICS DISABLED (temporarily commented out): {props.mode === "collection" ? ( ) : ( )} */} {props.mode === "collection" ? : } ); } // ANALYTICS DISABLED (temporarily commented out): // 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). */} {content.collection.showResults} ); } 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). */} {content.search.label} {content.search.submit} {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} {content.collection.showResults} )} ); } 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 ( {content.collection.sortBy} event.currentTarget.form?.requestSubmit()} aria-busy={isLoading} className="w-auto" > {options.map((option) => ( {option.label} ))} ); } 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} ); }
{collection.description}
{showingCount}
{content.collection.noProducts}
{content.search.noResults.replace("{{ terms }}", `\u201c${term}\u201d`)}
{content.search.noResultsSuggestion}
{content.search.resultsFor .replace("{{ count }}", String(totalCount)) .replace("{{ terms }}", `\u201c${term}\u201d`)}