From b4113ac4c8d25f3cc23cff7a4519c94f2b3508b1 Mon Sep 17 00:00:00 2001 From: Chloe Date: Wed, 8 May 2024 16:10:44 +0700 Subject: [PATCH] feat: implement products infinite loading Signed-off-by: Chloe --- app/search/[collection]/page.tsx | 84 ++------------ app/search/page.tsx | 25 ++-- components/grid/index.tsx | 8 +- components/layout/product-grid-items.tsx | 28 ----- components/layout/products-list/actions.ts | 109 ++++++++++++++++++ components/layout/products-list/index.tsx | 91 +++++++++++++++ .../layout/search/filters/mobile-filters.tsx | 2 +- lib/shopify/index.ts | 24 ++-- lib/shopify/queries/collection.ts | 4 +- lib/shopify/queries/product.ts | 9 +- lib/shopify/types.ts | 6 +- 11 files changed, 263 insertions(+), 127 deletions(-) delete mode 100644 components/layout/product-grid-items.tsx create mode 100644 components/layout/products-list/actions.ts create mode 100644 components/layout/products-list/index.tsx diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index 8cc6f3e14..7318486d1 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -1,25 +1,18 @@ -import { getCollection, getCollectionProducts } from 'lib/shopify'; +import { getCollection } from 'lib/shopify'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import Breadcrumb from 'components/breadcrumb'; import BreadcrumbHome from 'components/breadcrumb/breadcrumb-home'; import Grid from 'components/grid'; -import ProductGridItems from 'components/layout/product-grid-items'; +import ProductsList from 'components/layout/products-list'; +import { getProductsInCollection } from 'components/layout/products-list/actions'; import FiltersList from 'components/layout/search/filters/filters-list'; import MobileFilters from 'components/layout/search/filters/mobile-filters'; import SubMenu from 'components/layout/search/filters/sub-menu'; import Header, { HeaderPlaceholder } from 'components/layout/search/header'; import ProductsGridPlaceholder from 'components/layout/search/placeholder'; import SortingMenu from 'components/layout/search/sorting-menu'; -import { - AVAILABILITY_FILTER_ID, - PRICE_FILTER_ID, - PRODUCT_METAFIELD_PREFIX, - VARIANT_METAFIELD_PREFIX, - defaultSort, - sorting -} from 'lib/constants'; import { Suspense } from 'react'; export const runtime = 'edge'; @@ -40,58 +33,6 @@ export async function generateMetadata({ }; } -const constructFilterInput = (filters: { - [key: string]: string | string[] | undefined; -}): Array => { - const results = [] as Array; - Object.entries(filters) - .filter(([key]) => !key.startsWith(PRICE_FILTER_ID)) - .forEach(([key, value]) => { - const [namespace, metafieldKey] = key.split('.').slice(-2); - const values = Array.isArray(value) ? value : [value]; - - if (key === AVAILABILITY_FILTER_ID) { - results.push({ - available: value === 'true' - }); - } else if (key.startsWith(PRODUCT_METAFIELD_PREFIX)) { - results.push( - ...values.map((v) => ({ - productMetafield: { - namespace, - key: metafieldKey, - value: v - } - })) - ); - } else if (key.startsWith(VARIANT_METAFIELD_PREFIX)) { - results.push( - ...values.map((v) => ({ - variantMetafield: { - namespace, - key: metafieldKey, - value: v - } - })) - ); - } - }); - - const price = {} as { min?: number; max?: number }; - - if (filters[`${PRICE_FILTER_ID}.min`]) { - price.min = Number(filters[`${PRICE_FILTER_ID}.min`]); - } - if (filters[`${PRICE_FILTER_ID}.max`]) { - price.max = Number(filters[`${PRICE_FILTER_ID}.max`]); - !price.min && (price.min = 0); - } - if (price.max || price.min) { - results.push({ price }); - } - return results; -}; - async function CategoryPage({ params, searchParams @@ -99,15 +40,8 @@ async function CategoryPage({ params: { collection: string }; searchParams?: { [key: string]: string | string[] | undefined }; }) { - const { sort, q, collection: _collection, ...rest } = searchParams as { [key: string]: string }; - const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; - - const filtersInput = constructFilterInput(rest); - const { products, filters } = await getCollectionProducts({ - collection: params.collection, - sortKey, - reverse, - ...(filtersInput.length ? { filters: filtersInput } : {}) + const { products, filters, pageInfo } = await getProductsInCollection({ + searchParams }); return ( @@ -128,7 +62,13 @@ async function CategoryPage({ {products.length === 0 ? (

{`No products found in this collection`}

) : ( - + )} diff --git a/app/search/page.tsx b/app/search/page.tsx index 2f7a53bd4..c64ecb3fc 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,8 +1,7 @@ import Grid from 'components/grid'; -import ProductGridItems from 'components/layout/product-grid-items'; -import { defaultSort, sorting } from 'lib/constants'; -import { getProducts } from 'lib/shopify'; - +import ProductsList from 'components/layout/products-list'; +import { searchProducts } from 'components/layout/products-list/actions'; +import SortingMenu from 'components/layout/search/sorting-menu'; export const runtime = 'edge'; export const metadata = { @@ -15,25 +14,31 @@ export default async function SearchPage({ }: { searchParams?: { [key: string]: string | string[] | undefined }; }) { - const { sort, q: searchValue } = searchParams as { [key: string]: string }; - const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; - - const products = await getProducts({ sortKey, reverse, query: searchValue }); + const { q: searchValue } = searchParams as { [key: string]: string }; + const { products, pageInfo } = await searchProducts({ searchParams }); const resultsText = products.length > 1 ? 'results' : 'result'; return ( <> +
+ +
{searchValue ? (

{products.length === 0 ? 'There are no products that match ' - : `Showing ${products.length} ${resultsText} for `} + : `Showing ${resultsText} for `} "{searchValue}"

) : null} {products.length > 0 ? ( - + ) : null} diff --git a/components/grid/index.tsx b/components/grid/index.tsx index 92681555a..ec60772f5 100644 --- a/components/grid/index.tsx +++ b/components/grid/index.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx'; +import { forwardRef } from 'react'; function Grid(props: React.ComponentProps<'ul'>) { return ( @@ -8,14 +9,15 @@ function Grid(props: React.ComponentProps<'ul'>) { ); } -function GridItem(props: React.ComponentProps<'li'>) { +const GridItem = forwardRef>((props, ref) => { return ( -
  • +
  • {props.children}
  • ); -} +}); +GridItem.displayName = 'GridItem'; Grid.Item = GridItem; export default Grid; diff --git a/components/layout/product-grid-items.tsx b/components/layout/product-grid-items.tsx deleted file mode 100644 index 0f51e1141..000000000 --- a/components/layout/product-grid-items.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Grid from 'components/grid'; -import { GridTileImage } from 'components/grid/tile'; -import { Product } from 'lib/shopify/types'; -import Link from 'next/link'; - -export default function ProductGridItems({ products }: { products: Product[] }) { - return ( - <> - {products.map((product) => ( - - - - - - ))} - - ); -} diff --git a/components/layout/products-list/actions.ts b/components/layout/products-list/actions.ts new file mode 100644 index 000000000..b04743672 --- /dev/null +++ b/components/layout/products-list/actions.ts @@ -0,0 +1,109 @@ +'use server'; + +import { + AVAILABILITY_FILTER_ID, + PRICE_FILTER_ID, + PRODUCT_METAFIELD_PREFIX, + VARIANT_METAFIELD_PREFIX, + defaultSort, + sorting +} from 'lib/constants'; +import { getCollectionProducts, getProducts } from 'lib/shopify'; + +const constructFilterInput = (filters: { + [key: string]: string | string[] | undefined; +}): Array => { + const results = [] as Array; + Object.entries(filters) + .filter(([key]) => !key.startsWith(PRICE_FILTER_ID)) + .forEach(([key, value]) => { + const [namespace, metafieldKey] = key.split('.').slice(-2); + const values = Array.isArray(value) ? value : [value]; + + if (key === AVAILABILITY_FILTER_ID) { + results.push({ + available: value === 'true' + }); + } else if (key.startsWith(PRODUCT_METAFIELD_PREFIX)) { + results.push( + ...values.map((v) => ({ + productMetafield: { + namespace, + key: metafieldKey, + value: v + } + })) + ); + } else if (key.startsWith(VARIANT_METAFIELD_PREFIX)) { + results.push( + ...values.map((v) => ({ + variantMetafield: { + namespace, + key: metafieldKey, + value: v + } + })) + ); + } + }); + + const price = {} as { min?: number; max?: number }; + + if (filters[`${PRICE_FILTER_ID}.min`]) { + price.min = Number(filters[`${PRICE_FILTER_ID}.min`]); + } + if (filters[`${PRICE_FILTER_ID}.max`]) { + price.max = Number(filters[`${PRICE_FILTER_ID}.max`]); + !price.min && (price.min = 0); + } + if (price.max || price.min) { + results.push({ price }); + } + return results; +}; + +export const getProductsInCollection = async ({ + searchParams, + afterCursor +}: { + searchParams?: { + [key: string]: string | string[] | undefined; + }; + afterCursor?: string; +}) => { + const { sort, q, collection, ...rest } = searchParams as { [key: string]: string }; + const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; + const filtersInput = constructFilterInput(rest); + + const response = await getCollectionProducts({ + collection: collection as string, + sortKey, + reverse, + ...(filtersInput.length ? { filters: filtersInput } : {}), + ...(afterCursor ? { after: afterCursor } : {}) + }); + + return response; +}; + +export const searchProducts = async ({ + searchParams, + afterCursor +}: { + searchParams?: { + [key: string]: string | string[] | undefined; + }; + afterCursor?: string; +}) => { + const { sort, q: searchValue } = searchParams as { [key: string]: string }; + const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; + + const response = await getProducts({ + sortKey, + reverse, + query: searchValue, + ...(afterCursor ? { after: afterCursor } : {}) + }); + + return response; +}; diff --git a/components/layout/products-list/index.tsx b/components/layout/products-list/index.tsx new file mode 100644 index 000000000..ffdd516fc --- /dev/null +++ b/components/layout/products-list/index.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Grid from 'components/grid'; +import { GridTileImage } from 'components/grid/tile'; +import { Product } from 'lib/shopify/types'; +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; +import { getProductsInCollection, searchProducts } from './actions'; + +const ProductsList = ({ + initialProducts, + pageInfo, + searchParams, + page +}: { + initialProducts: Product[]; + pageInfo: { + endCursor: string; + hasNextPage: boolean; + }; + searchParams?: { [key: string]: string | string[] | undefined }; + page: 'search' | 'collection'; +}) => { + const [products, setProducts] = useState(initialProducts); + const [_pageInfo, setPageInfo] = useState(pageInfo); + const lastElement = useRef(null); + + useEffect(() => { + const lastElementRef = lastElement.current; + + const loadMoreProducts = async () => { + const params = { + searchParams, + afterCursor: _pageInfo.endCursor + }; + const { products, pageInfo } = + page === 'collection' + ? await getProductsInCollection(params) + : await searchProducts(params); + + setProducts((prev) => [...prev, ...products]); + setPageInfo({ + hasNextPage: pageInfo.hasNextPage, + endCursor: pageInfo.endCursor + }); + }; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMoreProducts(); + } + }, + { threshold: 1 } + ); + lastElementRef && observer.observe(lastElementRef); + + return () => { + if (lastElementRef) { + observer.unobserve(lastElementRef); + } + }; + }, [_pageInfo.endCursor, page, searchParams]); + + return ( + <> + {products.map((product, index) => ( + + + + + + ))} + + ); +}; + +export default ProductsList; diff --git a/components/layout/search/filters/mobile-filters.tsx b/components/layout/search/filters/mobile-filters.tsx index 787ced8e1..9b5c8ed65 100644 --- a/components/layout/search/filters/mobile-filters.tsx +++ b/components/layout/search/filters/mobile-filters.tsx @@ -7,7 +7,7 @@ import { Filter } from 'lib/shopify/types'; import { Fragment, ReactNode, useState } from 'react'; import Filters from './filters-list'; -const MobileFilters = ({ filters, menu }: { filters: Filter[]; menu: ReactNode }) => { +const MobileFilters = ({ filters, menu }: { filters: Filter[]; menu?: ReactNode }) => { const [openDialog, setOpenDialog] = useState(false); return ( diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index 34d2ddc70..bcc19b6c2 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -361,12 +361,14 @@ export async function getCollectionProducts({ collection, reverse, sortKey, - filters + filters, + after }: { collection: string; reverse?: boolean; sortKey?: string; filters?: Array; + after?: string; }): Promise<{ products: Product[]; filters: Filter[]; pageInfo: PageInfo }> { const res = await shopifyFetch({ query: getCollectionProductsQuery, @@ -375,7 +377,8 @@ export async function getCollectionProducts({ handle: collection, reverse, sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey, - filters + filters, + after } }); @@ -501,25 +504,30 @@ export async function getProductRecommendations(productId: string): Promise { + after?: string; +}): Promise<{ products: Product[]; pageInfo: PageInfo }> { const res = await shopifyFetch({ query: getProductsQuery, tags: [TAGS.products], variables: { query, reverse, - sortKey + sortKey, + after } }); - - return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); + const pageInfo = res.body.data.products.pageInfo; + return { + products: reshapeProducts(removeEdgesAndNodes(res.body.data.products)), + pageInfo + }; } - // This is called from `app/api/revalidate.ts` so providers can control revalidation logic. export async function revalidate(req: NextRequest): Promise { console.log(`Receiving revalidation request from Shopify.`); diff --git a/lib/shopify/queries/collection.ts b/lib/shopify/queries/collection.ts index e284b6bc6..3a36d5660 100644 --- a/lib/shopify/queries/collection.ts +++ b/lib/shopify/queries/collection.ts @@ -42,14 +42,14 @@ export const getCollectionProductsQuery = /* GraphQL */ ` $sortKey: ProductCollectionSortKeys $reverse: Boolean $filters: [ProductFilter!] + $after: String ) { collection(handle: $handle) { - products(sortKey: $sortKey, filters: $filters, reverse: $reverse, first: 100) { + products(sortKey: $sortKey, filters: $filters, reverse: $reverse, first: 50, after: $after) { edges { node { ...product } - cursor } filters { id diff --git a/lib/shopify/queries/product.ts b/lib/shopify/queries/product.ts index d3f12bd0f..e1f7e74c2 100644 --- a/lib/shopify/queries/product.ts +++ b/lib/shopify/queries/product.ts @@ -10,13 +10,18 @@ export const getProductQuery = /* GraphQL */ ` `; export const getProductsQuery = /* GraphQL */ ` - query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) { - products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) { + query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String, $after: String) { + products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 50, after: $after) { edges { node { ...product } } + pageInfo { + endCursor + startCursor + hasNextPage + } } } ${productFragment} diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 5c3bf5c31..79ba849ae 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -240,6 +240,7 @@ export type ShopifyCollectionProductsOperation = { reverse?: boolean; sortKey?: string; filters?: Array; + after?: string; }; }; @@ -292,12 +293,15 @@ export type ShopifyProductRecommendationsOperation = { export type ShopifyProductsOperation = { data: { - products: Connection; + products: Connection & { + pageInfo: PageInfo; + }; }; variables: { query?: string; reverse?: boolean; sortKey?: string; + after?: string; }; };