x
This commit is contained in:
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}>
|
||||
|
||||
Reference in new issue
Block a user