Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/product-videos-pdp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

Display product videos (YouTube) on the PDP gallery. The Storefront GraphQL API exposes product videos as a `{ title, url }` pair (`Product.videos`); Catalyst now fetches them and renders each one alongside the product images using [`lite-youtube-embed`](https://github.com/paulirish/lite-youtube-embed) — a lightweight facade that loads the YouTube player only when a shopper clicks. A small `getYouTubeId()` helper extracts the video id from the watch URL the API returns.
12 changes: 12 additions & 0 deletions core/app/[locale]/(default)/product/[slug]/page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,18 @@ const StreamableProductQuery = graphql(
altText
url: urlTemplate(lossy: true)
}
# Product videos. The Storefront GraphQL API only returns the video
# title and url (a YouTube watch URL); the gallery renders it via
# lite-youtube-embed. 25 covers realistic product video counts without
# needing pagination.
videos(first: 25) {
edges {
node {
title
url
}
}
}
sku
weight {
value
Expand Down
8 changes: 8 additions & 0 deletions core/app/[locale]/(default)/product/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,19 @@ export default async function Product({ params, searchParams }: Props) {
alt: image.altText,
}));

// POC: Storefront GraphQL returns product videos as { title, url }. We pass
// them straight through; the gallery turns each url into an embed player.
const videos = removeEdgesAndNodes(product.videos).map((video) => ({
url: video.url,
title: video.title,
}));

return {
images: product.defaultImage
? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images]
: images,
pageInfo: product.images.pageInfo,
videos,
};
});

Expand Down
2 changes: 2 additions & 0 deletions core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@
"backorderQuantity": "{quantity, number} will be on backorder",
"loadingMoreImages": "Loading more images",
"imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}",
"playVideo": "Play video",
"viewVideo": "View video",
"Submit": {
"addToCart": "Add to cart",
"outOfStock": "Out of stock",
Expand Down
4 changes: 4 additions & 0 deletions core/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export default async (): Promise<NextConfig> => {
experimental: {
optimizePackageImports: ['@icons-pack/react-simple-icons'],
},
images: {
// Allow product-video poster thumbnails (YouTube) through next/image.
remotePatterns: [{ protocol: 'https', hostname: 'i.ytimg.com', pathname: '/vi/**' }],
},
typescript: {
ignoreBuildErrors: !!process.env.CI,
},
Expand Down
3 changes: 2 additions & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@
"clsx": "^2.1.1",
"content-security-policy-builder": "^2.3.0",
"deepmerge": "^4.3.1",
"dompurify": "^3.3.1",
"embla-carousel": "9.0.0-rc01",
"embla-carousel-autoplay": "9.0.0-rc01",
"embla-carousel-fade": "9.0.0-rc01",
"embla-carousel-react": "9.0.0-rc01",
"gql.tada": "^1.8.10",
"graphql": "^16.11.0",
"dompurify": "^3.3.1",
"jose": "^5.10.0",
"lite-youtube-embed": "^0.3.4",
"lodash.debounce": "^4.0.8",
"lru-cache": "^11.1.0",
"lucide-react": "^0.474.0",
Expand Down
3 changes: 3 additions & 0 deletions core/vibes/soul/sections/product-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ProductDetailProduct {
images: Streamable<{
images: Array<{ src: string; alt: string }>;
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
videos?: Array<{ url: string; title: string }>;
}>;
price?: Streamable<Price | null>;
subtitle?: string;
Expand Down Expand Up @@ -140,6 +141,7 @@ export function ProductDetail<F extends Field>({
loadMoreAction={loadMoreImagesAction}
pageInfo={imagesData.pageInfo}
productId={Number(product.id)}
videos={imagesData.videos}
/>
)}
</Stream>
Expand Down Expand Up @@ -210,6 +212,7 @@ export function ProductDetail<F extends Field>({
pageInfo={imagesData.pageInfo}
productId={Number(product.id)}
thumbnailLabel={thumbnailLabel}
videos={imagesData.videos}
/>
)}
</Stream>
Expand Down
47 changes: 47 additions & 0 deletions core/vibes/soul/sections/product-detail/lite-youtube.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

// lite-youtube-embed is a tiny, dependency-free custom element that renders a
// YouTube facade (poster + play button) and only injects the real iframe on
// click — the standard "third-party facade" pattern. The CSS styles the element.
import 'lite-youtube-embed/src/lite-yt-embed.css';

import type { CSSProperties } from 'react';

// Register the <lite-youtube> custom element on the client only: the package
// subclasses HTMLElement at import time, which isn't defined during SSR. The
// element still renders as inert markup on the server and upgrades on hydration.
if (typeof window !== 'undefined') {
void import('lite-youtube-embed');
}

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'lite-youtube': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
videoid: string;
playlabel?: string;
params?: string;
};
}
}
}

export interface LiteYouTubeProps {
videoId: string;
/** Visually-hidden label for the play button (accessibility). */
playLabel: string;
className?: string;
style?: CSSProperties;
}

export function LiteYouTube({ videoId, playLabel, className, style }: LiteYouTubeProps) {
return (
<lite-youtube
className={className}
params="rel=0"
playlabel={playLabel}
style={style}
videoid={videoId}
/>
);
}
146 changes: 123 additions & 23 deletions core/vibes/soul/sections/product-detail/product-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import * as Skeleton from '@/vibes/soul/primitives/skeleton';
import { Image } from '~/components/image';

import { LiteYouTube } from './lite-youtube';
import { getYouTubeId, getYouTubePosterUrl } from './video-embed';

export interface ProductVideo {
url: string;
title: string;
}

export type ProductGalleryLoadMoreAction = (
productId: number,
cursor: string,
Expand All @@ -20,6 +28,8 @@ export type ProductGalleryLoadMoreAction = (

export interface ProductGalleryProps {
images: Array<{ alt: string; src: string }>;
/** Product videos (YouTube), rendered as players after the images. */
videos?: ProductVideo[];
className?: string;
thumbnailLabel?: string;
aspectRatio?:
Expand Down Expand Up @@ -57,6 +67,7 @@ export interface ProductGalleryProps {
*/
export function ProductGallery({
images: initialImages,
videos = [],
className,
thumbnailLabel = 'View image number',
aspectRatio = '4:5',
Expand All @@ -67,6 +78,12 @@ export function ProductGallery({
}: ProductGalleryProps) {
const t = useTranslations('Product.ProductDetails');

// Resolve product videos to YouTube ids up front; non-YouTube URLs are skipped
// (YouTube is the only supported product-video provider).
const youtubeVideos = videos
.map((video) => ({ id: getYouTubeId(video.url), title: video.title }))
.filter((video): video is { id: string; title: string } => video.id !== null);

const [images, setImages] = useState(initialImages);
const [pageInfo, setPageInfo] = useState(initialPageInfo);
const [hasMoreToLoad, setHasMoreToLoad] = useState(initialPageInfo?.hasNextPage ?? false);
Expand All @@ -77,18 +94,25 @@ export function ProductGallery({
const scrollListenerRef = useRef<() => void>(() => undefined);
const listenForScrollRef = useRef(true);
const hasMoreToLoadRef = useRef(hasMoreToLoad);
// Number of loaded image slides; used to anchor the load-more sentinel to the
// image thumbnails (which always precede the video thumbnails).
const imageCountRef = useRef(images.length);

const [emblaRef, emblaApi] = useEmblaCarousel();
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({
containScroll: 'keepSnaps',
dragFree: true,
});

// Keep ref in sync with state
// Keep refs in sync with state
useEffect(() => {
hasMoreToLoadRef.current = hasMoreToLoad;
}, [hasMoreToLoad]);

useEffect(() => {
imageCountRef.current = images.length;
}, [images.length]);

const onThumbClick = useCallback(
(index: number) => {
if (!emblaApi || !emblaThumbsApi) return;
Expand All @@ -99,8 +123,8 @@ export function ProductGallery({

const onSelect = useCallback(() => {
if (!emblaApi || !emblaThumbsApi) return;
setSelectedIndex(emblaApi.selectedSnap());

setSelectedIndex(emblaApi.selectedSnap());
emblaThumbsApi.goTo(emblaApi.selectedSnap());
}, [emblaApi, emblaThumbsApi]);

Expand Down Expand Up @@ -191,14 +215,16 @@ export function ProductGallery({
(thumbsApi: EmblaCarouselType) => {
if (!listenForScrollRef.current) return;

const slideCount = thumbsApi.slideNodes().length;
const lastSlideIndex = slideCount - 1;
const secondLastSlideIndex = slideCount - 2;
// Anchor the sentinel to the IMAGE thumbnails (always the first slides),
// not the overall slide count — video thumbnails render after the images
// and the load-more skeleton, so using slideNodes().length would delay
// pagination until the shopper scrolled past the videos.
const lastImageIndex = imageCountRef.current - 1;
const slidesInView = thumbsApi.slidesInView();

// Trigger when last or second-to-last thumbnail is in view
// Trigger when the last or second-to-last image thumbnail is in view
const shouldLoadMore =
slidesInView.includes(lastSlideIndex) || slidesInView.includes(secondLastSlideIndex);
slidesInView.includes(lastImageIndex) || slidesInView.includes(lastImageIndex - 1);

if (shouldLoadMore) {
loadMore(thumbsApi);
Expand Down Expand Up @@ -232,6 +258,20 @@ export function ProductGallery({
};
}, [emblaThumbsApi, addThumbsScrollListener, onSlideChanges]);

const aspectRatioClass = {
'5:6': 'aspect-[5/6]',
'3:4': 'aspect-[3/4]',
'4:5': 'aspect-[4/5]',
'3:2': 'aspect-[3/2]',
'2:3': 'aspect-[2/3]',
'16:9': 'aspect-[16/9]',
'9:16': 'aspect-[9/16]',
'6:5': 'aspect-[6/5]',
'5:4': 'aspect-[5/4]',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
}[aspectRatio];

return (
<div className={clsx('sticky top-4 flex flex-col gap-2', className)}>
<div aria-live="polite" className="sr-only" role="status">
Expand All @@ -241,22 +281,7 @@ export function ProductGallery({
<div className="flex">
{images.map((image, idx) => (
<div
className={clsx(
'relative w-full shrink-0 grow-0 basis-full',
{
'5:6': 'aspect-[5/6]',
'3:4': 'aspect-[3/4]',
'4:5': 'aspect-[4/5]',
'3:2': 'aspect-[3/2]',
'2:3': 'aspect-[2/3]',
'16:9': 'aspect-[16/9]',
'9:16': 'aspect-[9/16]',
'6:5': 'aspect-[6/5]',
'5:4': 'aspect-[5/4]',
'4:3': 'aspect-[4/3]',
'1:1': 'aspect-square',
}[aspectRatio],
)}
className={clsx('relative w-full shrink-0 grow-0 basis-full', aspectRatioClass)}
key={idx}
>
<Image
Expand All @@ -275,6 +300,31 @@ export function ProductGallery({
/>
</div>
))}
{youtubeVideos.map((video, idx) => {
// Video slides follow every image, so the carousel index continues
// from images.length.
const slideIndex = images.length + idx;

return (
<div
className={clsx(
'relative flex w-full shrink-0 grow-0 basis-full items-center',
aspectRatioClass,
'bg-[var(--product-gallery-image-background,hsl(var(--contrast-100)))]',
)}
key={`video-${idx}`}
>
<LiteYouTube
className="w-full max-w-none"
// Remount when this slide is no longer the selected one so a
// playing video stops (and frees its audio) on navigate-away.
key={selectedIndex === slideIndex ? 'selected' : 'idle'}
playLabel={`${t('playVideo')}: ${video.title}`}
videoId={video.id}
/>
</div>
);
})}
</div>
</div>

Expand Down Expand Up @@ -319,6 +369,56 @@ export function ProductGallery({
<Skeleton.Box className="h-12 w-12 shrink-0 rounded-lg @md:h-16 @md:w-16" />
</div>
)}
{youtubeVideos.map((video, vIdx) => {
// Video slides are rendered after every image in the main
// carousel, so the carousel index continues from images.length.
const slideIndex = images.length + vIdx;

return (
<button
aria-label={`${t('viewVideo')}: ${video.title}`}
className={clsx(
'relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--product-gallery-focus,hsl(var(--primary)))] focus-visible:ring-offset-2 @md:h-16 @md:w-16',
slideIndex === selectedIndex
? 'border-[var(--product-gallery-image-border-active,hsl(var(--foreground)))]'
: 'border-transparent',
)}
key={`video-thumb-${vIdx}`}
onClick={() => onThumbClick(slideIndex)}
type="button"
>
<div
className={clsx(
'relative h-full w-full bg-[var(--product-gallery-image-background,hsl(var(--contrast-100)))]',
slideIndex === selectedIndex ? 'opacity-100' : 'opacity-50',
'transition-all duration-300 hover:opacity-100',
)}
>
<Image
alt={video.title}
className="object-cover"
fill
sizes="(min-width: 28rem) 4rem, 3rem"
src={getYouTubePosterUrl(video.id)}
/>
{/* Play badge overlay marks the thumbnail as a video. */}
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white">
<svg
aria-hidden="true"
fill="currentColor"
height="12"
viewBox="0 0 24 24"
width="12"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</div>
</button>
);
})}
</div>
</div>
</div>
Expand Down
Loading
Loading