-
Notifications
You must be signed in to change notification settings - Fork 283
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
1,939 additions
and
557 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
# Hydrogen example: infinite scroll collection page | ||
|
||
This folder contains an example implementation of [infinite scroll](https://shopify.dev/docs/custom-storefronts/hydrogen/data-fetching/pagination#automatically-load-pages-on-scroll) within a product collection page using the [Pagination component](https://shopify.dev/docs/api/hydrogen/2024-01/components/pagination). | ||
|
||
The example uses [`react-intersection-observer`](https://www.npmjs.com/package/react-intersection-observer) to detect when the `Load more` button is in view. A `useEffect` then triggers a navigation to the next page url, which seemlessly loads more products as the user scrolls. | ||
|
||
A few side effects of this implementation are: | ||
|
||
1. The page progressively enhances, so that when JavaScript has yet to load, the page is still interactive because the user can still click the `Load more` button. | ||
2. As the user scrolls, the URL automatically changes as new pages are loaded. | ||
3. Because the implementation uses the `Pagination` component, navigating back to the collection list after clicking on a product automatically maintains the user's scroll position. | ||
|
||
## Key files | ||
|
||
This folder contains the minimal set of files needed to showcase the implementation. | ||
Files that aren’t included by default with Hydrogen and that you’ll need to | ||
create are labeled with 🆕. | ||
|
||
| File | Description | | ||
| -------------------------------------------------------------------------- | --------------------------- | | ||
| [`app/routes/collections.$handle.tsx`](app/routes/collections.$handle.tsx) | The product collection page | | ||
|
||
## Instructions | ||
|
||
### 1. Link your store to inject the required environment variables | ||
|
||
```bash | ||
h2 link | ||
``` | ||
|
||
### 2. Edit the route loader | ||
|
||
In `app/routes/collections.$handle.tsx`, update the `pageBy` parameter passed to tghe `getPaginationVariables` function call to customize how many products to load at a time. | ||
|
||
```ts | ||
const paginationVariables = getPaginationVariables(request, { | ||
pageBy: 8, | ||
}); | ||
``` |
231 changes: 231 additions & 0 deletions
231
examples/infinite-scroll/app/routes/collections.$handle.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; | ||
import { | ||
useLoaderData, | ||
useNavigate, | ||
Link, | ||
type MetaFunction, | ||
} from '@remix-run/react'; | ||
import { | ||
Pagination, | ||
getPaginationVariables, | ||
Image, | ||
Money, | ||
} from '@shopify/hydrogen'; | ||
import type {ProductItemFragment} from 'storefrontapi.generated'; | ||
import {useEffect} from 'react'; | ||
import {useVariantUrl} from '~/lib/variants'; | ||
import {useInView} from 'react-intersection-observer'; | ||
|
||
export const meta: MetaFunction<typeof loader> = ({data}) => { | ||
return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}]; | ||
}; | ||
|
||
export async function loader({request, params, context}: LoaderFunctionArgs) { | ||
const {handle} = params; | ||
const {storefront} = context; | ||
const paginationVariables = getPaginationVariables(request, { | ||
pageBy: 8, | ||
}); | ||
|
||
if (!handle) { | ||
return redirect('/collections'); | ||
} | ||
|
||
const {collection} = await storefront.query(COLLECTION_QUERY, { | ||
variables: {handle, ...paginationVariables}, | ||
}); | ||
|
||
if (!collection) { | ||
throw new Response(`Collection ${handle} not found`, { | ||
status: 404, | ||
}); | ||
} | ||
return json({collection}); | ||
} | ||
|
||
export default function Collection() { | ||
const {collection} = useLoaderData<typeof loader>(); | ||
const {ref, inView, entry} = useInView(); | ||
|
||
return ( | ||
<div className="collection"> | ||
<h1>{collection.title}</h1> | ||
<p className="collection-description">{collection.description}</p> | ||
<Pagination connection={collection.products}> | ||
{({ | ||
nodes, | ||
isLoading, | ||
PreviousLink, | ||
NextLink, | ||
state, | ||
nextPageUrl, | ||
hasNextPage, | ||
}) => ( | ||
<> | ||
<PreviousLink> | ||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>} | ||
</PreviousLink> | ||
<ProductsGrid | ||
products={nodes} | ||
inView={inView} | ||
hasNextPage={hasNextPage} | ||
nextPageUrl={nextPageUrl} | ||
state={state} | ||
/> | ||
<br /> | ||
<NextLink ref={ref}> | ||
<span ref={ref}> | ||
{isLoading ? 'Loading...' : <span>Load more yeah ↓</span>} | ||
</span> | ||
</NextLink> | ||
</> | ||
)} | ||
</Pagination> | ||
</div> | ||
); | ||
} | ||
|
||
function ProductsGrid({ | ||
products, | ||
inView, | ||
hasNextPage, | ||
nextPageUrl, | ||
state, | ||
}: { | ||
products: ProductItemFragment[]; | ||
inView: boolean; | ||
hasNextPage: boolean; | ||
nextPageUrl: string; | ||
state: any; | ||
}) { | ||
const navigate = useNavigate(); | ||
|
||
useEffect(() => { | ||
if (inView && hasNextPage) { | ||
navigate(nextPageUrl, { | ||
replace: true, | ||
preventScrollReset: true, | ||
state, | ||
}); | ||
} | ||
}, [inView, navigate, state, nextPageUrl, hasNextPage]); | ||
|
||
return ( | ||
<div className="products-grid"> | ||
{products.map((product, index) => { | ||
return ( | ||
<ProductItem | ||
key={product.id} | ||
product={product} | ||
loading={index < 8 ? 'eager' : undefined} | ||
/> | ||
); | ||
})} | ||
</div> | ||
); | ||
} | ||
|
||
function ProductItem({ | ||
product, | ||
loading, | ||
}: { | ||
product: ProductItemFragment; | ||
loading?: 'eager' | 'lazy'; | ||
}) { | ||
const variant = product.variants.nodes[0]; | ||
const variantUrl = useVariantUrl(product.handle, variant.selectedOptions); | ||
return ( | ||
<Link | ||
className="product-item" | ||
key={product.id} | ||
prefetch="intent" | ||
to={variantUrl} | ||
> | ||
{product.featuredImage && ( | ||
<Image | ||
alt={product.featuredImage.altText || product.title} | ||
aspectRatio="1/1" | ||
data={product.featuredImage} | ||
loading={loading} | ||
sizes="(min-width: 45em) 400px, 100vw" | ||
/> | ||
)} | ||
<h4>{product.title}</h4> | ||
<small> | ||
<Money data={product.priceRange.minVariantPrice} /> | ||
</small> | ||
</Link> | ||
); | ||
} | ||
|
||
const PRODUCT_ITEM_FRAGMENT = `#graphql | ||
fragment MoneyProductItem on MoneyV2 { | ||
amount | ||
currencyCode | ||
} | ||
fragment ProductItem on Product { | ||
id | ||
handle | ||
title | ||
featuredImage { | ||
id | ||
altText | ||
url | ||
width | ||
height | ||
} | ||
priceRange { | ||
minVariantPrice { | ||
...MoneyProductItem | ||
} | ||
maxVariantPrice { | ||
...MoneyProductItem | ||
} | ||
} | ||
variants(first: 1) { | ||
nodes { | ||
selectedOptions { | ||
name | ||
value | ||
} | ||
} | ||
} | ||
} | ||
` as const; | ||
|
||
// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection | ||
const COLLECTION_QUERY = `#graphql | ||
${PRODUCT_ITEM_FRAGMENT} | ||
query Collection( | ||
$handle: String! | ||
$country: CountryCode | ||
$language: LanguageCode | ||
$first: Int | ||
$last: Int | ||
$startCursor: String | ||
$endCursor: String | ||
) @inContext(country: $country, language: $language) { | ||
collection(handle: $handle) { | ||
id | ||
handle | ||
title | ||
description | ||
products( | ||
first: $first, | ||
last: $last, | ||
before: $startCursor, | ||
after: $endCursor | ||
) { | ||
nodes { | ||
...ProductItem | ||
} | ||
pageInfo { | ||
hasPreviousPage | ||
hasNextPage | ||
endCursor | ||
startCursor | ||
} | ||
} | ||
} | ||
} | ||
` as const; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
{ | ||
"name": "example-infinite-scroll", | ||
"private": true, | ||
"prettier": "@shopify/prettier-config", | ||
"scripts": { | ||
"build": "shopify hydrogen build --diff", | ||
"dev": "shopify hydrogen dev --codegen --diff", | ||
"preview": "npm run build && shopify hydrogen preview", | ||
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", | ||
"typecheck": "tsc --noEmit", | ||
"codegen": "shopify hydrogen codegen" | ||
}, | ||
"dependencies": { | ||
"@remix-run/react": "^2.5.1", | ||
"@remix-run/server-runtime": "^2.5.1", | ||
"@shopify/cli": "3.52.0", | ||
"@shopify/cli-hydrogen": "^7.0.0", | ||
"@shopify/hydrogen": "~2024.1.0", | ||
"@shopify/remix-oxygen": "^2.0.3", | ||
"@total-typescript/ts-reset": "^0.4.2", | ||
"graphql": "^16.6.0", | ||
"graphql-tag": "^2.12.6", | ||
"isbot": "^3.6.6", | ||
"react": "^18.2.0", | ||
"react-dom": "^18.2.0", | ||
"react-intersection-observer": "^8.32.1" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"extends": "../../templates/skeleton/tsconfig.json", | ||
"include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], | ||
"compilerOptions": { | ||
"baseUrl": ".", | ||
"paths": { | ||
"*": ["./*", "../../templates/skeleton/*"], | ||
"~/*": ["app/*", "../../templates/skeleton/app/*"] | ||
} | ||
} | ||
} |
Oops, something went wrong.