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",
|
"type": "search-results",
|
||||||
"props": {
|
"props": {
|
||||||
"id": "search-results",
|
"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 { useCollections } from '@/hooks/use-shopify-collections';
|
||||||
import CollectionCard from './collection-card';
|
import CollectionCard from './collection-card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
|
||||||
interface CollectionsProps {
|
interface CollectionsProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -32,7 +33,15 @@ const Collections: React.FC<CollectionsProps> = ({
|
|||||||
title = 'Our Collections',
|
title = 'Our Collections',
|
||||||
subtitle = 'Discover our carefully crafted worlds',
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +101,21 @@ const Collections: React.FC<CollectionsProps> = ({
|
|||||||
<CollectionCard key={collection.id} collection={collection} />
|
<CollectionCard key={collection.id} collection={collection} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SearchResults from '@/components/shopify/search-results';
|
|||||||
|
|
||||||
export type SearchResultsBlockProps = {
|
export type SearchResultsBlockProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
||||||
@@ -12,9 +13,11 @@ const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
|||||||
category: 'commerce',
|
category: 'commerce',
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
title: 'Search',
|
title: 'Search',
|
||||||
|
placeholder: 'Search products',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||||
|
placeholder: { label: 'Search placeholder', type: 'text' },
|
||||||
},
|
},
|
||||||
render: (props) => <SearchResults {...props} />,
|
render: (props) => <SearchResults {...props} />,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import ProductCard from './product-card';
|
import ProductCard from './product-card';
|
||||||
import ProductFilters from './product-filters';
|
import ProductFilters from './product-filters';
|
||||||
import ProductToolbar from './product-toolbar';
|
import ProductToolbar from './product-toolbar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Loader } from '@/components/ui/loader';
|
import { Loader } from '@/components/ui/loader';
|
||||||
|
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
|
||||||
import {
|
import {
|
||||||
searchProducts,
|
searchProducts,
|
||||||
type SearchFilter,
|
type SearchFilter,
|
||||||
@@ -30,11 +32,17 @@ const SORT_OPTIONS: SortOption[] = [
|
|||||||
|
|
||||||
interface SearchResultsProps {
|
interface SearchResultsProps {
|
||||||
title?: string;
|
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 searchParams = useSearchParams();
|
||||||
const query = searchParams.get('q') ?? '';
|
const query = searchParams.get('q') ?? '';
|
||||||
|
const [term, setTerm] = useState(query);
|
||||||
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
||||||
@@ -53,6 +61,24 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
// Stabilize the effect dependency by filter contents.
|
// Stabilize the effect dependency by filter contents.
|
||||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -126,7 +152,34 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
|||||||
{title}
|
{title}
|
||||||
</h1>
|
</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
|
<ProductToolbar
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onOpenFilters={() => setFiltersOpen(true)}
|
onOpenFilters={() => setFiltersOpen(true)}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { gql } from '@shopify/hydrogen';
|
|||||||
import { ProductFragment } from '@/graphql/products';
|
import { ProductFragment } from '@/graphql/products';
|
||||||
|
|
||||||
export const GET_COLLECTIONS_QUERY = gql(`
|
export const GET_COLLECTIONS_QUERY = gql(`
|
||||||
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
|
query GetCollections(
|
||||||
@inContext(country: $country, language: $language) {
|
$first: Int!
|
||||||
collections(first: $first) {
|
$after: String
|
||||||
|
$country: CountryCode
|
||||||
|
$language: LanguageCode
|
||||||
|
) @inContext(country: $country, language: $language) {
|
||||||
|
collections(first: $first, after: $after) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
getCollections,
|
getCollections,
|
||||||
|
getCollectionsPage,
|
||||||
getCollectionProducts,
|
getCollectionProducts,
|
||||||
} from '@/services/shopify/shop';
|
} from '@/services/shopify/shop';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getCollections,
|
getCollections,
|
||||||
|
getCollectionsPage,
|
||||||
getCollectionProducts,
|
getCollectionProducts,
|
||||||
getCollectionProductsPage,
|
getCollectionProductsPage,
|
||||||
} from '@/services/shopify/shop';
|
} from '@/services/shopify/shop';
|
||||||
export type {
|
export type {
|
||||||
Collection,
|
Collection,
|
||||||
|
CollectionsPage,
|
||||||
CollectionWithProducts,
|
CollectionWithProducts,
|
||||||
CollectionSortKey,
|
CollectionSortKey,
|
||||||
CollectionProductsPage,
|
CollectionProductsPage,
|
||||||
@@ -36,14 +39,19 @@ interface UseCollectionProductsOptions {
|
|||||||
export function useCollections(first = 50) {
|
export function useCollections(first = 50) {
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [hasNextPage, setHasNextPage] = useState(false);
|
||||||
|
|
||||||
const fetchCollections = useCallback(async () => {
|
const fetchCollections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await getCollections(first);
|
const page = await getCollectionsPage(first);
|
||||||
setCollections(data);
|
setCollections(page.collections);
|
||||||
|
setCursor(page.endCursor);
|
||||||
|
setHasNextPage(page.hasNextPage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching collections:', err);
|
console.error('Error fetching collections:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load collections');
|
setError(err instanceof Error ? err.message : 'Failed to load collections');
|
||||||
@@ -56,7 +64,35 @@ export function useCollections(first = 50) {
|
|||||||
fetchCollections();
|
fetchCollections();
|
||||||
}, [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.
|
// Fetch once on demand for menus that may never open.
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"@reacteditor/plugin-shopify": "0.0.2",
|
"@reacteditor/plugin-shopify": "0.0.2",
|
||||||
"@reacteditor/plugin-tailwind-cdn": "0.0.4",
|
"@reacteditor/plugin-tailwind-cdn": "0.0.4",
|
||||||
"@remixicon/react": "^4.9.0",
|
"@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",
|
"@shopify/storefront-api-client": "^1.0.0",
|
||||||
"ai": "^7",
|
"ai": "^7",
|
||||||
"class-variance-authority": "0.7.1",
|
"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[]> {
|
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(
|
const data = unwrapStorefrontResult(
|
||||||
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
|
await storefront.graphql(GET_COLLECTIONS_QUERY, {
|
||||||
|
variables: { first, after },
|
||||||
|
}),
|
||||||
'GetCollections'
|
'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(
|
export async function getCollectionProducts(
|
||||||
|
|||||||
@@ -2404,22 +2404,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607":
|
"@shopify/hydrogen@npm:2026.10.0-preview.1":
|
||||||
version: 0.0.0-preview-116d5d7-20260730141607
|
version: 2026.10.0-preview.1
|
||||||
resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607"
|
resolution: "@shopify/hydrogen@npm:2026.10.0-preview.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
gql.tada: "npm:1.9.2"
|
gql.tada: "npm:1.9.2"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
vue:
|
vue:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
hydrogen: bin/hydrogen.mjs
|
hydrogen: bin/hydrogen.mjs
|
||||||
checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd
|
checksum: 10c0/3fdc23d9ac756634bf5a84b6fec1adad5f1e11c2a9bb901fe0928235ddeae0b50048fe5c1594a0baa0cfe9477b69067c63d9a0903ef49a2b4ee4aa7b1be0d18c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -5828,7 +5831,7 @@ __metadata:
|
|||||||
"@reacteditor/plugin-shopify": "npm:0.0.2"
|
"@reacteditor/plugin-shopify": "npm:0.0.2"
|
||||||
"@reacteditor/plugin-tailwind-cdn": "npm:0.0.4"
|
"@reacteditor/plugin-tailwind-cdn": "npm:0.0.4"
|
||||||
"@remixicon/react": "npm:^4.9.0"
|
"@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"
|
"@shopify/storefront-api-client": "npm:^1.0.0"
|
||||||
"@tailwindcss/postcss": "npm:^4"
|
"@tailwindcss/postcss": "npm:^4"
|
||||||
"@types/hast": "npm:^3.0.5"
|
"@types/hast": "npm:^3.0.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user