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

@@ -1,4 +1,4 @@
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload } from '@homaroma/types';
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload, DividerSectionPayload } from '@homaroma/types';
import DynamicHero from './DynamicHero';
import DynamicHeroV2 from './DynamicHeroV2';
import DynamicHeroVideo from './DynamicHeroVideo';
@@ -18,6 +18,7 @@ import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
import DynamicQuoteCallout from './DynamicQuoteCallout';
import DynamicProductCarousel from './DynamicProductCarousel';
import DynamicRichText from './DynamicRichText';
import PageDivider from './PageDivider';
import AccordionSection from './sections/AccordionSection';
import ContactInfoSection from './sections/ContactInfoSection';
import ContactFormSection from './sections/ContactFormSection';
@@ -91,6 +92,8 @@ export default function DynamicPageRenderer({
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
case 'HERO_CAROUSEL':
return <DynamicHeroCarousel key={section.id} payload={section.payload} />;
case 'DIVIDER':
return <PageDivider key={section.id} payload={section.payload as DividerSectionPayload} />;
case 'USP_BAR':
return <DynamicUspBar key={section.id} payload={section.payload} />;
+55
View File
@@ -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-pop-black/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-pop-black/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,217 @@
import { useEffect, useState, useSyncExternalStore } from 'react'
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { HeroCarouselSectionPayload, TranslatableString } from '@homaroma/types'
interface ProductHeroCarouselProps {
payload: HeroCarouselSectionPayload
lang: string
}
function resolveLocalized(value: TranslatableString | null | undefined, lang: string): string {
if (!value) return ''
if (typeof value === 'string') return value
return value[lang] ?? value[lang.slice(0, 2)] ?? value.th ?? value.en ?? Object.values(value)[0] ?? ''
}
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-pop-bg px-6 py-3 text-sm font-bold uppercase tracking-widest text-pop-black transition hover:bg-pop-bg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg focus-visible:ring-offset-2 focus-visible:ring-offset-pop-black'
return href.startsWith('/') ? (
<Link to={href} className={className}>{children}</Link>
) : (
<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-pop-bg'
return href.startsWith('/') ? (
<Link to={href} className={className}><span className="sr-only">{label}</span></Link>
) : (
<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-pop-black/5 ${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}
>
<picture>
{slide.mobileImageUrl && <source media="(max-width: 767px)" srcSet={slide.mobileImageUrl} />}
<img
src={slide.imageUrl}
alt={alt}
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
</picture>
{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-pop-black/40 px-6 py-12 text-center text-pop-bg">
<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-pop-bg/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-pop-bg/40 bg-pop-black/35 p-2.5 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg 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-pop-bg/40 bg-pop-black/35 p-2.5 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg 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-pop-black/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-pop-bg ${index === currentIndex ? 'w-7 bg-pop-bg' : 'w-2.5 bg-pop-bg/50 hover:bg-pop-bg/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-pop-bg/40 bg-pop-black/35 p-2 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg"
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>
)
}
+16
View File
@@ -6,6 +6,8 @@ import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery'
import ProductHeroCarousel from '../components/ProductHeroCarousel'
import ProductDivider from '../components/ProductDivider'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type ProductDetailSection, type TranslatableString } from '../types'
import { useCartStore } from '../store/cartStore'
@@ -474,6 +476,20 @@ export default function ProductDetailPage() {
return renderProductNativeSection(section, index);
}
if (section.type.toUpperCase() === 'HERO_CAROUSEL') {
return (
<ProductHeroCarousel
key={section.id}
payload={section.payload}
lang={i18n.language}
/>
);
}
if (section.type.toUpperCase() === 'DIVIDER') {
return <ProductDivider key={section.id} payload={section.payload} />;
}
return (
<DynamicPageRenderer
key={section.id}