143 lines
5.6 KiB
TypeScript
143 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import { getImageProps } from 'next/image'
|
|
import type { ProductImage } from '@homaroma/types'
|
|
|
|
interface Props {
|
|
productName: string
|
|
categoryName?: string
|
|
images: ProductImage[]
|
|
stretchDesktop?: boolean
|
|
}
|
|
|
|
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 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 = () => {
|
|
const width = el.clientWidth
|
|
if (width > 0) {
|
|
const newIndex = Math.round(el.scrollLeft / width)
|
|
if (newIndex >= 0 && newIndex < imageCount) setActiveIndex(newIndex)
|
|
}
|
|
}
|
|
el.addEventListener('scrollend', onScrollEnd)
|
|
return () => el.removeEventListener('scrollend', onScrollEnd)
|
|
}, [imageCount])
|
|
|
|
const handleThumbnailClick = (idx: number) => {
|
|
setActiveIndex(idx)
|
|
const scroller = scrollRef.current
|
|
const child = scroller?.children[idx] as HTMLElement | undefined
|
|
if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
|
}
|
|
|
|
if (imageCount === 0) return null
|
|
|
|
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={frameClass}>
|
|
<ResponsiveProductImage image={sortedImages[0]} alt={productName} fetchPriority="high" sizes="(min-width: 768px) 50vw, 100vw" className={mainImageClass} />
|
|
{categoryName && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<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 || `${image.url}-${idx}`}
|
|
type="button"
|
|
onClick={() => handleThumbnailClick(idx)}
|
|
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'}`}
|
|
>
|
|
<ResponsiveProductImage image={image} alt={`${productName} thumbnail ${idx + 1}`} sizes="96px" className="h-full w-full object-cover" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<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="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>
|
|
)}
|
|
|
|
<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>
|
|
</div>
|
|
)
|
|
}
|