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:
co-authored by
Claude Opus 5
parent
9ed1cc60de
commit
3bc61066f0
@@ -18,7 +18,8 @@
|
||||
"type": "search-results",
|
||||
"props": {
|
||||
"id": "search-results",
|
||||
"title": "Search"
|
||||
"title": "Search",
|
||||
"placeholder": "Search products"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user