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

+82 -69
View File
@@ -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>