Skip to content

Commit 6a23c90

Browse files
authored
feat(core): enable product image pagination (#2863)
* feat(core): enable product image pagination * feat: infinite gallery, no thumbnail * fix: infinite scroll in thumbnail scroll * fix: retains scroll position * fix: infinite scroll fix * fix: change product image to 12 per page * fix: remove label prop * fix: update loading animation * fix: add accessbility * chore: update changeset * fix: merge steamable functions * fix: update changeset * fix: remove @ts-expect-errors * fix: non-nullable assertion
1 parent d78bc85 commit 6a23c90

14 files changed

Lines changed: 456 additions & 113 deletions

File tree

.changeset/mighty-zebras-turn.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Add pagination support for the product gallery. When a product has more images than the initial page load, new images will load as batches once the user reaches the end of the existing thumbnails. Thumbnail images now will display in horizontal direction in all viewport sizes.
6+
7+
## Migration
8+
9+
1. Create the new server action file `core/app/[locale]/(default)/product/[slug]/_actions/get-more-images.ts` with a GraphQL query to fetch additional product images with pagination.
10+
11+
2. Update the product page data fetching in `core/app/[locale]/(default)/product/[slug]/page-data.ts` to include `pageInfo` (with `hasNextPage` and `endCursor`) from the images query.
12+
13+
3. Update `core/app/[locale]/(default)/product/[slug]/page.tsx` to pass the new pagination props (`pageInfo`, `productId`, `loadMoreAction`) to the `ProductDetail` component.
14+
15+
4. The `ProductGallery` component now accepts optional props for pagination:
16+
- `pageInfo?: { hasNextPage: boolean; endCursor: string | null }`
17+
- `productId?: number`
18+
- `loadMoreAction?: ProductGalleryLoadMoreAction`
19+
20+
Due to the number of changes, it is recommended to use the PR as a reference for migration.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use server';
2+
3+
import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client';
4+
5+
import { getSessionCustomerAccessToken } from '~/auth';
6+
import { client } from '~/client';
7+
import { graphql } from '~/client/graphql';
8+
import { revalidate } from '~/client/revalidate-target';
9+
10+
const MoreProductImagesQuery = graphql(`
11+
query MoreProductImagesQuery($entityId: Int!, $first: Int!, $after: String!) {
12+
site {
13+
product(entityId: $entityId) {
14+
images(first: $first, after: $after) {
15+
pageInfo {
16+
hasNextPage
17+
endCursor
18+
}
19+
edges {
20+
node {
21+
altText
22+
url: urlTemplate(lossy: true)
23+
}
24+
}
25+
}
26+
}
27+
}
28+
}
29+
`);
30+
31+
export async function getMoreProductImages(
32+
productId: number,
33+
cursor: string,
34+
limit = 12,
35+
): Promise<{
36+
images: Array<{ src: string; alt: string }>;
37+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
38+
}> {
39+
const customerAccessToken = await getSessionCustomerAccessToken();
40+
41+
const { data } = await client.fetch({
42+
document: MoreProductImagesQuery,
43+
variables: { entityId: productId, first: limit, after: cursor },
44+
customerAccessToken,
45+
fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } },
46+
});
47+
48+
const images = removeEdgesAndNodes(data.site.product?.images ?? { edges: [] });
49+
50+
return {
51+
images: images.map((img) => ({ src: img.url, alt: img.altText })),
52+
pageInfo: data.site.product?.images.pageInfo ?? { hasNextPage: false, endCursor: null },
53+
};
54+
}

core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ const getReviews = cache(async (productId: number, paginationArgs: object) => {
7777
interface Props {
7878
productId: number;
7979
searchParams: Promise<SearchParams>;
80-
streamableImages: Streamable<Array<{ src: string; alt: string }>>;
80+
streamableImages: Streamable<{
81+
images: Array<{ src: string; alt: string }>;
82+
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
83+
}>;
8184
streamableProduct: Streamable<Awaited<ReturnType<typeof getStreamableProduct>>>;
8285
}
8386

core/app/[locale]/(default)/product/[slug]/page-data.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,12 @@ const StreamableProductQuery = graphql(
274274
optionValueIds: $optionValueIds
275275
useDefaultOptionSelections: $useDefaultOptionSelections
276276
) {
277-
images {
277+
entityId
278+
images(first: 12) {
279+
pageInfo {
280+
hasNextPage
281+
endCursor
282+
}
278283
edges {
279284
node {
280285
altText

core/app/[locale]/(default)/product/[slug]/page.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { productOptionsTransformer } from '~/data-transformers/product-options-t
1414
import { getPreferredCurrencyCode } from '~/lib/currency';
1515

1616
import { addToCart } from './_actions/add-to-cart';
17+
import { getMoreProductImages } from './_actions/get-more-images';
1718
import { submitReview } from './_actions/submit-review';
1819
import { ProductAnalyticsProvider } from './_components/product-analytics-provider';
1920
import { ProductSchema } from './_components/product-schema';
@@ -182,9 +183,12 @@ export default async function Product({ params, searchParams }: Props) {
182183
alt: image.altText,
183184
}));
184185

185-
return product.defaultImage
186-
? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images]
187-
: images;
186+
return {
187+
images: product.defaultImage
188+
? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images]
189+
: images,
190+
pageInfo: product.images.pageInfo,
191+
};
188192
});
189193

190194
const streameableCtaLabel = Streamable.from(async () => {
@@ -546,6 +550,7 @@ export default async function Product({ params, searchParams }: Props) {
546550
emptySelectPlaceholder={t('ProductDetails.emptySelectPlaceholder')}
547551
fields={productOptionsTransformer(baseProduct.productOptions)}
548552
incrementLabel={t('ProductDetails.increaseQuantity')}
553+
loadMoreImagesAction={getMoreProductImages}
549554
prefetch={true}
550555
product={{
551556
id: baseProduct.entityId.toString(),

core/messages/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,8 @@
463463
"additionalInformation": "Additional information",
464464
"currentStock": "{quantity, number} in stock",
465465
"backorderQuantity": "{quantity, number} will be on backorder",
466+
"loadingMoreImages": "Loading more images",
467+
"imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}",
466468
"Submit": {
467469
"addToCart": "Add to cart",
468470
"outOfStock": "Out of stock",

core/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@
4646
"clsx": "^2.1.1",
4747
"content-security-policy-builder": "^2.3.0",
4848
"deepmerge": "^4.3.1",
49-
"embla-carousel": "8.5.2",
50-
"embla-carousel-autoplay": "8.5.2",
51-
"embla-carousel-fade": "8.5.2",
52-
"embla-carousel-react": "8.5.2",
49+
"embla-carousel": "9.0.0-rc01",
50+
"embla-carousel-autoplay": "9.0.0-rc01",
51+
"embla-carousel-fade": "9.0.0-rc01",
52+
"embla-carousel-react": "9.0.0-rc01",
5353
"gql.tada": "^1.8.10",
5454
"graphql": "^16.11.0",
5555
"dompurify": "^3.3.1",

core/vibes/soul/primitives/carousel/index.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@ function Carousel({
5858
const onSelect = React.useCallback((api: CarouselApi) => {
5959
if (!api) return;
6060

61-
setCanScrollPrev(api.canScrollPrev());
62-
setCanScrollNext(api.canScrollNext());
61+
setCanScrollPrev(api.canGoToPrev());
62+
setCanScrollNext(api.canGoToNext());
6363
}, []);
6464

65-
const scrollPrev = useCallback(() => api?.scrollPrev(), [api]);
65+
const scrollPrev = useCallback(() => api?.goToPrev(), [api]);
6666

67-
const scrollNext = useCallback(() => api?.scrollNext(), [api]);
67+
const scrollNext = useCallback(() => api?.goToNext(), [api]);
6868

6969
const handleKeyDown = useCallback(
7070
(event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -89,7 +89,7 @@ function Carousel({
8989
if (!api) return;
9090

9191
onSelect(api);
92-
api.on('reInit', onSelect);
92+
api.on('reinit', onSelect);
9393
api.on('select', onSelect);
9494

9595
return () => {
@@ -225,7 +225,7 @@ function CarouselScrollbar({
225225
if (!api) return 0;
226226

227227
const point = nextProgress / 100;
228-
const snapList = api.scrollSnapList();
228+
const snapList = api.snapList();
229229

230230
if (snapList.length === 0) return -1;
231231

@@ -241,14 +241,14 @@ function CarouselScrollbar({
241241
useEffect(() => {
242242
if (!api) return;
243243

244-
const snapList = api.scrollSnapList();
244+
const snapList = api.snapList();
245245
const closestSnapIndex = findClosestSnap(progress);
246246
const scrollbarWidth = 100 / snapList.length;
247247
const scrollbarLeft = (closestSnapIndex / snapList.length) * 100;
248248

249249
setScrollbarPosition({ width: scrollbarWidth, left: scrollbarLeft });
250250

251-
api.scrollTo(closestSnapIndex);
251+
api.goTo(closestSnapIndex);
252252
}, [progress, api, findClosestSnap]);
253253

254254
useEffect(() => {
@@ -258,17 +258,17 @@ function CarouselScrollbar({
258258
if (!api) return;
259259

260260
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
261-
setProgress(api.scrollSnapList()[api.selectedScrollSnap()]! * 100);
261+
setProgress(api.snapList()[api.selectedSnap()]! * 100);
262262
}
263263

264264
api.on('select', onScroll);
265265
api.on('scroll', onScroll);
266-
api.on('reInit', onScroll);
266+
api.on('reinit', onScroll);
267267

268268
return () => {
269269
api.off('select', onScroll);
270270
api.off('scroll', onScroll);
271-
api.off('reInit', onScroll);
271+
api.off('reinit', onScroll);
272272
};
273273
}, [api]);
274274

core/vibes/soul/sections/product-detail/index.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { AnimatedUnderline } from '@/vibes/soul/primitives/animated-underline';
66
import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label';
77
import * as Skeleton from '@/vibes/soul/primitives/skeleton';
88
import { type Breadcrumb, Breadcrumbs } from '@/vibes/soul/sections/breadcrumbs';
9-
import { ProductGallery } from '@/vibes/soul/sections/product-detail/product-gallery';
9+
import {
10+
ProductGallery,
11+
ProductGalleryLoadMoreAction,
12+
} from '@/vibes/soul/sections/product-detail/product-gallery';
1013
import { ReviewForm, SubmitReviewAction } from '@/vibes/soul/sections/reviews/review-form';
1114

1215
import {
@@ -22,7 +25,10 @@ interface ProductDetailProduct {
2225
id: string;
2326
title: string;
2427
href: string;
25-
images: Streamable<Array<{ src: string; alt: string }>>;
28+
images: Streamable<{
29+
images: Array<{ src: string; alt: string }>;
30+
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
31+
}>;
2632
price?: Streamable<Price | null>;
2733
subtitle?: string;
2834
badge?: string;
@@ -68,6 +74,7 @@ export interface ProductDetailProps<F extends Field> {
6874
reviewFormTitleLabel?: string;
6975
reviewFormAction: SubmitReviewAction;
7076
user: Streamable<{ email: string; name: string }>;
77+
loadMoreImagesAction?: ProductGalleryLoadMoreAction;
7178
}
7279

7380
// eslint-disable-next-line valid-jsdoc
@@ -109,6 +116,7 @@ export function ProductDetail<F extends Field>({
109116
reviewFormTitleLabel,
110117
reviewFormAction,
111118
user,
119+
loadMoreImagesAction,
112120
}: ProductDetailProps<F>) {
113121
return (
114122
<section className="@container">
@@ -124,7 +132,14 @@ export function ProductDetail<F extends Field>({
124132
<div className="grid grid-cols-1 items-stretch gap-x-8 gap-y-8 @2xl:grid-cols-2 @5xl:gap-x-12">
125133
<div className="group/product-gallery hidden @2xl:block">
126134
<Stream fallback={<ProductGallerySkeleton />} value={product.images}>
127-
{(images) => <ProductGallery images={images} />}
135+
{(imagesData) => (
136+
<ProductGallery
137+
images={imagesData.images}
138+
loadMoreAction={loadMoreImagesAction}
139+
pageInfo={imagesData.pageInfo}
140+
productId={Number(product.id)}
141+
/>
142+
)}
128143
</Stream>
129144
</div>
130145
{/* Product Details */}
@@ -185,8 +200,14 @@ export function ProductDetail<F extends Field>({
185200
</div>
186201
<div className="group/product-gallery mb-8 @2xl:hidden">
187202
<Stream fallback={<ProductGallerySkeleton />} value={product.images}>
188-
{(images) => (
189-
<ProductGallery images={images} thumbnailLabel={thumbnailLabel} />
203+
{(imagesData) => (
204+
<ProductGallery
205+
images={imagesData.images}
206+
loadMoreAction={loadMoreImagesAction}
207+
pageInfo={imagesData.pageInfo}
208+
productId={Number(product.id)}
209+
thumbnailLabel={thumbnailLabel}
210+
/>
190211
)}
191212
</Stream>
192213
</div>

0 commit comments

Comments
 (0)