This commit is contained in:
Your Name committed 2026-10-02 07:10:25 +07:00
1 parent 75770ca122
commit f258e3e176
16 files changed
+1207 -348

No files matched your search

+70 -67
View File
@@ -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>
+3 -2
View File
@@ -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>