x
This commit is contained in:
1 parent
75770ca122
commit
f258e3e176
16 files changed
+1207
-348
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import type { ProductImage } from '../types'
|
||||
|
||||
interface ProductImageGalleryProps {
|
||||
@@ -6,59 +6,81 @@ interface ProductImageGalleryProps {
|
||||
fallback: string
|
||||
alt?: string
|
||||
badgeText?: string
|
||||
stretchDesktop?: boolean
|
||||
}
|
||||
|
||||
export default function ProductImageGallery({ images, fallback, alt = 'Product Image', badgeText }: ProductImageGalleryProps) {
|
||||
interface ResponsiveProductImageProps {
|
||||
image: ProductImage
|
||||
fallback: string
|
||||
alt: string
|
||||
className: string
|
||||
loading?: 'eager' | 'lazy'
|
||||
}
|
||||
|
||||
function ResponsiveProductImage({ image, fallback, alt, className, loading = 'lazy' }: ResponsiveProductImageProps) {
|
||||
const handleError = (event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const picture = event.currentTarget.parentElement
|
||||
picture?.querySelectorAll('source').forEach((source) => source.remove())
|
||||
event.currentTarget.src = fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<picture className="block h-full w-full">
|
||||
{image.mobileUrl && <source media="(max-width: 767px)" srcSet={image.mobileUrl} />}
|
||||
<img src={image.url} alt={alt} loading={loading} className={className} onError={handleError} />
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProductImageGallery({
|
||||
images,
|
||||
fallback,
|
||||
alt = 'Product Image',
|
||||
badgeText,
|
||||
stretchDesktop = false,
|
||||
}: ProductImageGalleryProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isScrollingRef = useRef(false)
|
||||
const sortedImages = images?.length
|
||||
? [...images].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
: [{ id: -1, url: fallback, mobileUrl: null, order: 0 }]
|
||||
const imageCount = sortedImages.length
|
||||
const visibleIndex = Math.min(currentIndex, Math.max(imageCount - 1, 0))
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const onScrollEnd = () => {
|
||||
if (isScrollingRef.current) return
|
||||
const width = el.clientWidth
|
||||
if (width > 0) {
|
||||
const newIndex = Math.round(el.scrollLeft / width)
|
||||
if (newIndex >= 0 && newIndex < (images?.length || 0)) {
|
||||
setCurrentIndex(newIndex)
|
||||
}
|
||||
if (newIndex >= 0 && newIndex < imageCount) setCurrentIndex(newIndex)
|
||||
}
|
||||
}
|
||||
el.addEventListener('scrollend', onScrollEnd)
|
||||
return () => el.removeEventListener('scrollend', onScrollEnd)
|
||||
}, [images?.length])
|
||||
}, [imageCount])
|
||||
|
||||
const handleThumbnailClick = (idx: number) => {
|
||||
setCurrentIndex(idx)
|
||||
if (scrollRef.current) {
|
||||
const child = scrollRef.current.children[idx] as HTMLElement
|
||||
if (child) {
|
||||
isScrollingRef.current = true
|
||||
scrollRef.current.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
||||
setTimeout(() => { isScrollingRef.current = false }, 600)
|
||||
}
|
||||
}
|
||||
const scroller = scrollRef.current
|
||||
const child = scroller?.children[idx] as HTMLElement | undefined
|
||||
if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Sort images by order if they exist, else just use the array
|
||||
const sortedImages = images?.length ? [...images].sort((a, b) => (a.order || 0) - (b.order || 0)) : []
|
||||
|
||||
if (sortedImages.length === 0) return null
|
||||
const frameClass = stretchDesktop
|
||||
? 'relative aspect-square w-full overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 md:aspect-auto md:h-full md:min-h-0'
|
||||
: 'relative aspect-square w-full overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10'
|
||||
const mainImageClass = stretchDesktop
|
||||
? 'h-full w-full object-cover md:object-contain'
|
||||
: 'h-full w-full object-cover'
|
||||
|
||||
// If there's only one image, don't show thumbnails
|
||||
if (sortedImages.length === 1) {
|
||||
if (imageCount === 1) {
|
||||
return (
|
||||
<div className="relative aspect-square w-full flex-1 overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 flex items-center justify-center">
|
||||
<img
|
||||
src={sortedImages[0].url}
|
||||
className="w-full h-full object-cover"
|
||||
alt={alt}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
||||
/>
|
||||
<div className={frameClass}>
|
||||
<ResponsiveProductImage image={sortedImages[0]} fallback={fallback} alt={alt} loading="eager" className={mainImageClass} />
|
||||
{badgeText && (
|
||||
<div className="absolute top-6 left-6 bg-pop-bg backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm pointer-events-none z-10">
|
||||
<div className="pointer-events-none absolute left-6 top-6 z-10 rounded-full bg-pop-bg px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm backdrop-blur-sm">
|
||||
{badgeText}
|
||||
</div>
|
||||
)}
|
||||
@@ -67,59 +89,40 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse md:flex-row items-start gap-4 w-full">
|
||||
{/* Thumbnails */}
|
||||
<div className="flex flex-row md:flex-col gap-2 md:gap-3 overflow-x-auto md:overflow-y-auto w-full md:w-20 lg:w-24 flex-none hide-scrollbar snap-x md:snap-y snap-mandatory pb-2 md:pb-0 md:pr-2">
|
||||
<div className={`flex w-full flex-col-reverse gap-4 md:flex-row ${stretchDesktop ? 'md:h-full md:min-h-0 md:items-stretch' : 'items-start'}`}>
|
||||
<div className={`hide-scrollbar flex w-full flex-none snap-x snap-mandatory flex-row gap-2 overflow-x-auto pb-2 md:w-20 md:snap-y md:flex-col md:gap-3 md:overflow-y-auto md:pb-0 md:pr-2 lg:w-24 ${stretchDesktop ? 'md:h-full md:min-h-0' : ''}`}>
|
||||
{sortedImages.map((image, idx) => (
|
||||
<button
|
||||
key={image.id || idx}
|
||||
key={image.id || `${image.url}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => handleThumbnailClick(idx)}
|
||||
className={`relative aspect-square w-14 md:w-full flex-none overflow-hidden rounded-xl md:rounded-2xl border-2 transition-all snap-center ${
|
||||
currentIndex === idx ? 'border-pop-pink shadow-md' : 'border-transparent opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
aria-label={`View ${alt} image ${idx + 1}`}
|
||||
aria-current={visibleIndex === idx ? 'true' : undefined}
|
||||
className={`relative aspect-square w-14 flex-none snap-center overflow-hidden rounded-xl border-2 transition-all md:w-full md:rounded-2xl ${visibleIndex === idx ? 'border-pop-pink shadow-md' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={`${alt} thumbnail ${idx + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
||||
/>
|
||||
<ResponsiveProductImage image={image} fallback={fallback} alt={`${alt} thumbnail ${idx + 1}`} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Image */}
|
||||
<div className="relative aspect-square w-full flex-1 overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 flex items-center justify-center">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar"
|
||||
>
|
||||
{sortedImages.map((img, idx) => (
|
||||
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`${alt} ${idx + 1}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
||||
/>
|
||||
<div className={`${frameClass} flex-1`}>
|
||||
<div ref={scrollRef} className="hide-scrollbar flex h-full w-full snap-x snap-mandatory overflow-x-auto">
|
||||
{sortedImages.map((image, idx) => (
|
||||
<div key={image.id || `${image.url}-${idx}`} className="relative block h-full w-full flex-none snap-center snap-always">
|
||||
<ResponsiveProductImage image={image} fallback={fallback} alt={`${alt} ${idx + 1}`} loading={idx === 0 ? 'eager' : 'lazy'} className={mainImageClass} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{badgeText && (
|
||||
<div className="absolute top-6 left-6 bg-pop-bg backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm pointer-events-none z-10">
|
||||
<div className="pointer-events-none absolute left-6 top-6 z-10 rounded-full bg-pop-bg px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm backdrop-blur-sm">
|
||||
{badgeText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Dots */}
|
||||
<div className="md:hidden absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10 pointer-events-none">
|
||||
{sortedImages.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`w-1.5 h-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${currentIndex === idx ? 'bg-white scale-125' : 'bg-white/50'}`}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-0 right-0 z-10 flex justify-center gap-2 md:hidden">
|
||||
{sortedImages.map((image, idx) => (
|
||||
<div key={image.id || `${image.url}-${idx}`} className={`h-1.5 w-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${visibleIndex === idx ? 'scale-125 bg-white' : 'bg-white/50'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,14 +248,15 @@ export default function ProductDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Product Detail */}
|
||||
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
|
||||
<div className="grid items-start gap-8 md:grid-cols-2 md:items-stretch md:gap-16">
|
||||
{/* Image Gallery */}
|
||||
<div className="w-full">
|
||||
<div className="w-full md:h-full">
|
||||
<ProductImageGallery
|
||||
images={product.images || []}
|
||||
fallback={fallbackImage}
|
||||
alt={displayName}
|
||||
badgeText={categoryName}
|
||||
stretchDesktop
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user