Add collections pagination and a search field on the search page

Paginate the collections grid with a "See more" button backed by a new
getCollectionsPage cursor API, add an editable search input to the
search-results block, and bump @shopify/hydrogen to 2026.10.0-preview.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqFkSaLYRPZ18dsMbzjiP4
This commit is contained in:
Rami Bitar
2026-08-23 11:01:48 -04:00
co-authored by Claude Opus 5
parent 9ed1cc60de
commit 3bc61066f0
9 changed files with 166 additions and 19 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"type": "search-results",
"props": {
"id": "search-results",
"title": "Search"
"title": "Search",
"placeholder": "Search products"
}
},
{
+25 -1
View File
@@ -4,6 +4,7 @@ import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
interface CollectionsProps {
title?: string;
@@ -32,7 +33,15 @@ const Collections: React.FC<CollectionsProps> = ({
title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds',
}) => {
const { collections, loading, error, refetch } = useCollections(12);
const {
collections,
loading,
loadingMore,
error,
hasNextPage,
loadMore,
refetch,
} = useCollections(12);
if (loading) {
return (
@@ -92,6 +101,21 @@ const Collections: React.FC<CollectionsProps> = ({
<CollectionCard key={collection.id} collection={collection} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={loadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'See more'}
</Button>
</div>
)}
</div>
</div>
);
@@ -4,6 +4,7 @@ import SearchResults from '@/components/shopify/search-results';
export type SearchResultsBlockProps = {
title?: string;
placeholder?: string;
};
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
@@ -12,9 +13,11 @@ const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
category: 'commerce',
defaultProps: {
title: 'Search',
placeholder: 'Search products',
},
fields: {
title: { label: 'Title', type: 'text', contentEditable: true },
placeholder: { label: 'Search placeholder', type: 'text' },
},
render: (props) => <SearchResults {...props} />,
};
+56 -3
View File
@@ -1,12 +1,14 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
import {
searchProducts,
type SearchFilter,
@@ -30,11 +32,17 @@ const SORT_OPTIONS: SortOption[] = [
interface SearchResultsProps {
title?: string;
placeholder?: string;
}
const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
const SearchResults: React.FC<SearchResultsProps> = ({
title = 'Search',
placeholder = 'Search products',
}) => {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get('q') ?? '';
const [term, setTerm] = useState(query);
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<SearchFilter[]>([]);
@@ -53,6 +61,24 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
// Stabilize the effect dependency by filter contents.
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Keep the field in step with the URL, which also changes from the header
// search dialog and from back/forward navigation.
useEffect(() => {
setTerm(query);
}, [query]);
const submitSearch = (event: React.FormEvent) => {
event.preventDefault();
const next = term.trim();
if (next === query) return;
router.push(next ? `/search?q=${encodeURIComponent(next)}` : '/search');
};
const clearSearch = () => {
setTerm('');
if (query) router.push('/search');
};
useEffect(() => {
let cancelled = false;
@@ -126,7 +152,34 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
{title}
</h1>
<div className="mt-6">
<form onSubmit={submitSearch} className="mt-6 max-w-xl">
<div className="relative">
<RiSearchLine
size={18}
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={placeholder}
aria-label="Search products"
className="h-11 rounded-none border-0 border-b border-border bg-transparent pl-7 pr-8 shadow-none focus-visible:border-foreground focus-visible:ring-0 dark:bg-transparent"
/>
{term && (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
<RiCloseLine size={18} />
</button>
)}
</div>
</form>
<div className="mt-8">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
+7 -3
View File
@@ -2,9 +2,13 @@ import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products';
export const GET_COLLECTIONS_QUERY = gql(`
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: $first) {
query GetCollections(
$first: Int!
$after: String
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
collections(first: $first, after: $after) {
edges {
node {
id
+39 -3
View File
@@ -3,16 +3,19 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getCollections,
getCollectionsPage,
getCollectionProducts,
} from '@/services/shopify/shop';
export {
getCollections,
getCollectionsPage,
getCollectionProducts,
getCollectionProductsPage,
} from '@/services/shopify/shop';
export type {
Collection,
CollectionsPage,
CollectionWithProducts,
CollectionSortKey,
CollectionProductsPage,
@@ -36,14 +39,19 @@ interface UseCollectionProductsOptions {
export function useCollections(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const fetchCollections = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await getCollections(first);
setCollections(data);
const page = await getCollectionsPage(first);
setCollections(page.collections);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Error fetching collections:', err);
setError(err instanceof Error ? err.message : 'Failed to load collections');
@@ -56,7 +64,35 @@ export function useCollections(first = 50) {
fetchCollections();
}, [fetchCollections]);
return { collections, loading, error, refetch: fetchCollections };
const loadMore = useCallback(async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionsPage(first, cursor);
setCollections((prev) => {
const seen = new Set(prev.map((collection) => collection.id));
return [...prev, ...page.collections.filter((c) => !seen.has(c.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more collections:', err);
} finally {
setLoadingMore(false);
}
}, [first, cursor, hasNextPage, loadingMore]);
return {
collections,
loading,
loadingMore,
error,
hasNextPage,
loadMore,
refetch: fetchCollections,
};
}
// Fetch once on demand for menus that may never open.
+1 -1
View File
@@ -21,7 +21,7 @@
"@reacteditor/plugin-shopify": "0.0.2",
"@reacteditor/plugin-tailwind-cdn": "0.0.4",
"@remixicon/react": "^4.9.0",
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
"@shopify/hydrogen": "2026.10.0-preview.1",
"@shopify/storefront-api-client": "^1.0.0",
"ai": "^7",
"class-variance-authority": "0.7.1",
+25 -2
View File
@@ -218,13 +218,36 @@ export interface ProductFilterFacet {
}>;
}
export interface CollectionsPage {
collections: Collection[];
hasNextPage: boolean;
endCursor: string | null;
}
export async function getCollections(first = 50): Promise<Collection[]> {
const page = await getCollectionsPage(first);
return page.collections;
}
export async function getCollectionsPage(
first = 50,
after: string | null = null
): Promise<CollectionsPage> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
await storefront.graphql(GET_COLLECTIONS_QUERY, {
variables: { first, after },
}),
'GetCollections'
);
return data.collections.edges.map((edge) => edge.node);
const { edges, pageInfo } = data.collections;
return {
collections: edges.map((edge) => edge.node),
hasNextPage: Boolean(pageInfo?.hasNextPage),
endCursor: pageInfo?.endCursor ?? null,
};
}
export async function getCollectionProducts(
+8 -5
View File
@@ -2404,22 +2404,25 @@ __metadata:
languageName: node
linkType: hard
"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607":
version: 0.0.0-preview-116d5d7-20260730141607
resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607"
"@shopify/hydrogen@npm:2026.10.0-preview.1":
version: 2026.10.0-preview.1
resolution: "@shopify/hydrogen@npm:2026.10.0-preview.1"
dependencies:
gql.tada: "npm:1.9.2"
peerDependencies:
react: ^18.0.0 || ^19.0.0
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.5.0
peerDependenciesMeta:
react:
optional: true
vite:
optional: true
vue:
optional: true
bin:
hydrogen: bin/hydrogen.mjs
checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd
checksum: 10c0/3fdc23d9ac756634bf5a84b6fec1adad5f1e11c2a9bb901fe0928235ddeae0b50048fe5c1594a0baa0cfe9477b69067c63d9a0903ef49a2b4ee4aa7b1be0d18c
languageName: node
linkType: hard
@@ -5828,7 +5831,7 @@ __metadata:
"@reacteditor/plugin-shopify": "npm:0.0.2"
"@reacteditor/plugin-tailwind-cdn": "npm:0.0.4"
"@remixicon/react": "npm:^4.9.0"
"@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607"
"@shopify/hydrogen": "npm:2026.10.0-preview.1"
"@shopify/storefront-api-client": "npm:^1.0.0"
"@tailwindcss/postcss": "npm:^4"
"@types/hast": "npm:^3.0.5"