This commit is contained in:
Your Name committed 2026-10-01 15:42:52 +07:00
1 parent 2d39edbd57
commit 78b722e120
24 files changed
+2130 -65

No files matched your search

@@ -8,6 +8,8 @@ import remarkGfm from 'remark-gfm'
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
import AddToCartSection from '@/components/AddToCartSection'
import ProductGallery from '@/components/ProductGallery'
import ProductHeroCarousel from '@/components/ProductHeroCarousel'
import ProductDivider from '@/components/ProductDivider'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
@@ -551,11 +553,16 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{productLayoutSections.map((section, index) => {
const sectionType = section.type.toUpperCase()
const renderedSection = isProductNativeSection(section)
? renderProductNativeSection(section, index)
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
: sectionType === 'HERO_CAROUSEL'
? <ProductHeroCarousel key={section.id} payload={section.payload} lang={lang} />
: sectionType === 'DIVIDER'
? <ProductDivider key={section.id} payload={section.payload} />
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
if (sectionType === 'PRODUCT_MAIN') {
const payload = payloadRecord(section)
return (
<div key={section.id} className="contents">
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface PageDividerProps {
payload: DividerSectionPayload
}
type PageDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function PageDivider({ payload }: PageDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: PageDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-line-primary/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface ProductDividerProps {
payload: DividerSectionPayload
}
type ProductDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function ProductDivider({ payload }: ProductDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: ProductDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-line-primary/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useEffect, useState, useSyncExternalStore } from 'react'
import Image from 'next/image'
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react'
import type { HeroCarouselSectionPayload } from '@homaroma/types'
import LocalizedLink from '@/components/LocalizedLink'
import { resolveLocalized } from '@/lib/localization'
interface ProductHeroCarouselProps {
payload: HeroCarouselSectionPayload
lang: string
}
function aspectClasses(
desktop: HeroCarouselSectionPayload['aspectRatio'],
mobile: HeroCarouselSectionPayload['mobileAspectRatio'],
) {
const mobileClass = {
'16/9': 'aspect-[16/9]',
'4/3': 'aspect-[4/3]',
'1/1': 'aspect-square',
'21/9': 'aspect-[21/9]',
auto: 'min-h-[22rem]',
}[mobile ?? '16/9']
const desktopClass = {
'21/6': 'md:aspect-[21/6]',
'21/7': 'md:aspect-[21/7]',
'21/9': 'md:aspect-[21/9]',
'16/5': 'md:aspect-[16/5]',
'16/6': 'md:aspect-[16/6]',
'4/1': 'md:aspect-[4/1]',
'5/1': 'md:aspect-[5/1]',
auto: 'md:aspect-auto md:min-h-[30rem]',
}[desktop ?? '21/7']
return `${mobileClass} ${desktopClass}`
}
function SlideLink({ href, children }: { href: string; children: React.ReactNode }) {
const className = 'inline-flex rounded-full bg-surface-card px-6 py-3 text-sm font-bold uppercase tracking-widest text-text-main transition hover:bg-surface-card/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card focus-visible:ring-offset-2 focus-visible:ring-offset-text-main'
return href.startsWith('/') ? (
<LocalizedLink href={href} className={className}>{children}</LocalizedLink>
) : (
<a href={href} className={className}>{children}</a>
)
}
function SlideOverlayLink({ href, label }: { href: string; label: string }) {
const className = 'absolute inset-0 z-[25] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-surface-card'
return href.startsWith('/') ? (
<LocalizedLink href={href} className={className}><span className="sr-only">{label}</span></LocalizedLink>
) : (
<a href={href} className={className}><span className="sr-only">{label}</span></a>
)
}
function subscribeToReducedMotion(onStoreChange: () => void) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
mediaQuery.addEventListener('change', onStoreChange)
return () => mediaQuery.removeEventListener('change', onStoreChange)
}
function getReducedMotionSnapshot() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
export default function ProductHeroCarousel({ payload, lang }: ProductHeroCarouselProps) {
const slides = payload.slides ?? []
const [activeIndex, setActiveIndex] = useState(0)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isInteracting, setIsInteracting] = useState(false)
const prefersReducedMotion = useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
() => false,
)
const slideCount = slides.length
const currentIndex = slideCount > 0 ? activeIndex % slideCount : 0
const autoplayConfigured = payload.autoplay !== false && slideCount > 1
const autoplay = autoplayConfigured && !prefersReducedMotion
const rotationPaused = isUserPaused || isInteracting
const interval = Math.max(payload.interval ?? 4500, 2000)
const labels = lang.startsWith('th')
? { carousel: 'โปรโมชันสินค้า', previous: 'สไลด์ก่อนหน้า', next: 'สไลด์ถัดไป', goTo: 'ไปยังสไลด์', pause: 'หยุดการเลื่อนสไลด์อัตโนมัติ', resume: 'เล่นสไลด์อัตโนมัติต่อ', open: 'เปิดโปรโมชัน' }
: { carousel: 'Product promotions', previous: 'Previous slide', next: 'Next slide', goTo: 'Go to slide', pause: 'Pause automatic slides', resume: 'Resume automatic slides', open: 'Open promotion' }
useEffect(() => {
if (!autoplay || rotationPaused) return
const timeoutId = window.setTimeout(() => {
setActiveIndex((index) => (index + 1) % slideCount)
}, interval)
return () => window.clearTimeout(timeoutId)
}, [autoplay, currentIndex, interval, rotationPaused, slideCount])
if (slideCount === 0) return null
const move = (direction: -1 | 1) => {
setActiveIndex((index) => (index + direction + slideCount) % slideCount)
}
return (
<section
className={`relative w-full overflow-hidden bg-surface-muted ${aspectClasses(payload.aspectRatio, payload.mobileAspectRatio)}`}
aria-roledescription="carousel"
aria-label={labels.carousel}
onMouseEnter={() => setIsInteracting(true)}
onMouseLeave={() => setIsInteracting(false)}
onFocusCapture={() => setIsInteracting(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsInteracting(false)
}}
>
<div className="absolute inset-0" aria-live={rotationPaused || prefersReducedMotion ? 'polite' : 'off'}>
{slides.map((slide, index) => {
const isActive = index === currentIndex
const title = resolveLocalized(slide.title, lang)
const subtitle = resolveLocalized(slide.subtitle, lang)
const buttonText = resolveLocalized(slide.buttonText, lang)
const alt = resolveLocalized(slide.alt, lang) || title
const focalPoint = slide.focalPoint
return (
<article
key={`${slide.imageUrl}-${index}`}
className={`absolute inset-0 ${prefersReducedMotion ? '' : 'transition-opacity duration-700'} ${isActive ? 'z-10 opacity-100' : 'pointer-events-none opacity-0'}`}
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} / ${slideCount}`}
aria-hidden={!isActive}
inert={!isActive}
>
{slide.mobileImageUrl && (
<Image
src={slide.mobileImageUrl}
alt={alt}
fill
sizes="100vw"
className="object-cover md:hidden"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
)}
<Image
src={slide.imageUrl}
alt={alt}
fill
sizes="100vw"
className={`object-cover ${slide.mobileImageUrl ? 'hidden md:block' : ''}`}
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
{slide.linkUrl && !buttonText && (
<SlideOverlayLink href={slide.linkUrl} label={alt || title || labels.open} />
)}
{(title || subtitle || (buttonText && slide.linkUrl)) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-text-main/40 px-6 py-12 text-center text-surface-card">
<div className="max-w-4xl space-y-4">
{title && <h2 className="text-3xl font-bold leading-tight md:text-5xl">{title}</h2>}
{subtitle && <p className="mx-auto max-w-2xl text-sm leading-relaxed text-surface-card/90 md:text-lg">{subtitle}</p>}
{buttonText && slide.linkUrl && (
<div className="pointer-events-auto pt-2"><SlideLink href={slide.linkUrl}>{buttonText}</SlideLink></div>
)}
</div>
</div>
)}
</article>
)
})}
</div>
{slideCount > 1 && payload.showArrows !== false && (
<>
<button
type="button"
onClick={() => move(-1)}
className="absolute left-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-surface-card/40 bg-text-main/35 p-2.5 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card md:left-6"
aria-label={labels.previous}
>
<ChevronLeft className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => move(1)}
className="absolute right-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-surface-card/40 bg-text-main/35 p-2.5 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card md:right-6"
aria-label={labels.next}
>
<ChevronRight className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
</>
)}
{slideCount > 1 && payload.showDots !== false && (
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 gap-2 rounded-full bg-text-main/35 px-3 py-2 backdrop-blur-sm">
{slides.map((slide, index) => (
<button
key={`${slide.imageUrl}-dot-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className={`h-2.5 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card ${index === currentIndex ? 'w-7 bg-surface-card' : 'w-2.5 bg-surface-card/50 hover:bg-surface-card/80'}`}
aria-label={`${labels.goTo} ${index + 1}`}
aria-current={index === currentIndex ? 'true' : undefined}
/>
))}
</div>
)}
{autoplayConfigured && !prefersReducedMotion && (
<button
type="button"
onClick={() => setIsUserPaused((paused) => !paused)}
className="absolute right-4 top-4 z-30 rounded-full border border-surface-card/40 bg-text-main/35 p-2 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card"
aria-label={isUserPaused ? labels.resume : labels.pause}
aria-pressed={isUserPaused}
title={isUserPaused ? labels.resume : labels.pause}
>
{isUserPaused ? <Play className="h-4 w-4" aria-hidden="true" /> : <Pause className="h-4 w-4" aria-hidden="true" />}
</button>
)}
</section>
)
}
@@ -4,7 +4,7 @@ import Image from 'next/image'
import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import type { Category, DividerSectionPayload, PageSection, Product } from '@homaroma/types'
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
@@ -14,6 +14,7 @@ import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import PageDivider from '../PageDivider'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -2268,6 +2269,8 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<HeroCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'DIVIDER':
return <PageDivider key={section.id} payload={payload as DividerSectionPayload} />
case 'HERO_MINIMAL':
return (
<ScrollReveal key={section.id}>