x
This commit is contained in:
1 parent
75770ca122
commit
f258e3e176
16 files changed
+1207
-348
No files matched your search
@@ -1,63 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { getImageProps } from 'next/image'
|
||||
import type { ProductImage } from '@homaroma/types'
|
||||
|
||||
interface Props {
|
||||
productName: string
|
||||
categoryName?: string
|
||||
images: string[]
|
||||
images: ProductImage[]
|
||||
stretchDesktop?: boolean
|
||||
}
|
||||
|
||||
export default function ProductGallery({ productName, categoryName, images }: Props) {
|
||||
interface ResponsiveProductImageProps {
|
||||
image: ProductImage
|
||||
alt: string
|
||||
className: string
|
||||
fetchPriority?: 'high' | 'low' | 'auto'
|
||||
sizes: string
|
||||
}
|
||||
|
||||
function ResponsiveProductImage({ image, alt, className, fetchPriority, sizes }: ResponsiveProductImageProps) {
|
||||
const desktop = getImageProps({
|
||||
src: image.url,
|
||||
alt,
|
||||
width: 1200,
|
||||
height: 1600,
|
||||
sizes,
|
||||
fetchPriority,
|
||||
}).props
|
||||
const mobile = image.mobileUrl
|
||||
? getImageProps({
|
||||
src: image.mobileUrl,
|
||||
alt,
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
sizes,
|
||||
fetchPriority,
|
||||
}).props
|
||||
: null
|
||||
|
||||
return (
|
||||
<picture className="block h-full w-full">
|
||||
{mobile && (
|
||||
<source media="(max-width: 767px)" srcSet={mobile.srcSet ?? mobile.src} sizes={mobile.sizes} />
|
||||
)}
|
||||
<img {...desktop} alt={alt} className={className} />
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProductGallery({ productName, categoryName, images, stretchDesktop = false }: Props) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isScrollingRef = useRef(false)
|
||||
const sortedImages = [...images].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
const imageCount = sortedImages.length
|
||||
const visibleIndex = Math.min(activeIndex, 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) {
|
||||
setActiveIndex(newIndex)
|
||||
}
|
||||
if (newIndex >= 0 && newIndex < imageCount) setActiveIndex(newIndex)
|
||||
}
|
||||
}
|
||||
el.addEventListener('scrollend', onScrollEnd)
|
||||
return () => el.removeEventListener('scrollend', onScrollEnd)
|
||||
}, [images.length])
|
||||
}, [imageCount])
|
||||
|
||||
const handleThumbnailClick = (idx: number) => {
|
||||
setActiveIndex(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' })
|
||||
}
|
||||
|
||||
if (images.length === 0) return null
|
||||
if (imageCount === 0) return null
|
||||
|
||||
// If there's only one image, don't show thumbnails
|
||||
if (images.length === 1) {
|
||||
const frameClass = stretchDesktop
|
||||
? 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card md:aspect-auto md:h-full md:min-h-0'
|
||||
: 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card'
|
||||
const mainImageClass = stretchDesktop
|
||||
? 'h-full w-full object-cover md:object-contain'
|
||||
: 'h-full w-full object-cover'
|
||||
|
||||
if (imageCount === 1) {
|
||||
return (
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
||||
<Image
|
||||
src={images[0]}
|
||||
alt={productName}
|
||||
fill
|
||||
priority={true}
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className={frameClass}>
|
||||
<ResponsiveProductImage image={sortedImages[0]} alt={productName} fetchPriority="high" sizes="(min-width: 768px) 50vw, 100vw" className={mainImageClass} />
|
||||
{categoryName && (
|
||||
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm pointer-events-none">
|
||||
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
|
||||
{categoryName}
|
||||
</div>
|
||||
)}
|
||||
@@ -66,61 +100,40 @@ export default function ProductGallery({ productName, categoryName, images }: Pr
|
||||
}
|
||||
|
||||
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">
|
||||
{images.map((img, idx) => (
|
||||
<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={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 ${
|
||||
activeIndex === idx ? 'border-brand-primary opacity-100' : 'border-transparent opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
aria-label={`View ${productName} 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-brand-primary opacity-100' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt={`${productName} thumbnail ${idx + 1}`}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
<ResponsiveProductImage image={image} alt={`${productName} thumbnail ${idx + 1}`} sizes="96px" 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-surface-card shadow-card">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar"
|
||||
>
|
||||
{images.map((img, idx) => (
|
||||
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block">
|
||||
<Image
|
||||
src={img}
|
||||
alt={`${productName} ${idx + 1}`}
|
||||
fill
|
||||
priority={idx === 0}
|
||||
className="object-cover"
|
||||
/>
|
||||
<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} alt={`${productName} ${idx + 1}`} fetchPriority={idx === 0 ? 'high' : 'auto'} sizes="(min-width: 768px) 46vw, 100vw" className={mainImageClass} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{categoryName && (
|
||||
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm pointer-events-none z-10">
|
||||
<div className="pointer-events-none absolute left-5 top-5 z-10 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
|
||||
{categoryName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Dots */}
|
||||
<div className="md:hidden absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10 pointer-events-none">
|
||||
{images.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 ${activeIndex === 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>
|
||||
|
||||
Reference in new issue
Block a user