This commit is contained in:
Your Name committed 2026-09-25 07:28:27 +07:00
1 parent 3ac0cb4f53
commit 5e724fae20
17 files changed
+2404 -278

No files matched your search

@@ -3,7 +3,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 } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard'
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -155,6 +156,129 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
)
}
function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const slides = Array.isArray(payload.slides) ? payload.slides : []
const autoplay = typeof payload.autoplay === 'boolean' ? payload.autoplay : true
const interval = typeof payload.interval === 'number' ? payload.interval : 4500
const showArrows = typeof payload.showArrows === 'boolean' ? payload.showArrows : true
const showDots = typeof payload.showDots === 'boolean' ? payload.showDots : true
const aspectRatio = typeof payload.aspectRatio === 'string' ? payload.aspectRatio : '21/7'
const mobileAspectRatio = typeof payload.mobileAspectRatio === 'string' ? payload.mobileAspectRatio : '16/9'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
if (!slides.length) return null
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]'
case '21/7': return 'md:aspect-[21/7]'
case '21/9': return 'md:aspect-[21/9]'
case '16/5': return 'md:aspect-[16/5]'
case '16/6': return 'md:aspect-[16/6]'
case '4/1': return 'md:aspect-[4/1]'
case '5/1': return 'md:aspect-[5/1]'
default: return 'md:aspect-[21/7]'
}
}
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]'
case '4/3': return 'aspect-[4/3]'
case '1/1': return 'aspect-[1/1]'
case '21/9': return 'aspect-[21/9]'
default: return 'aspect-[16/9]'
}
}
return (
<section className="relative w-full overflow-hidden bg-surface-page" style={{ backgroundColor }}>
<Swiper
modules={[Autoplay, Pagination, Navigation]}
slidesPerView={1}
loop={slides.length > 1}
autoplay={autoplay ? { delay: interval, disableOnInteraction: false } : false}
pagination={showDots ? { clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-brand-primary' } : false}
navigation={showArrows}
grabCursor={true}
className="w-full select-none [&_.swiper-button-next]:text-white [&_.swiper-button-prev]:text-white [&_.swiper-button-next]:after:text-sm md:[&_.swiper-button-next]:after:text-base [&_.swiper-button-prev]:after:text-sm md:[&_.swiper-button-prev]:after:text-base [&_.swiper-button-next]:w-8 md:[&_.swiper-button-next]:w-10 [&_.swiper-button-next]:h-8 md:[&_.swiper-button-next]:h-10 [&_.swiper-button-prev]:w-8 md:[&_.swiper-button-prev]:w-10 [&_.swiper-button-prev]:h-8 md:[&_.swiper-button-prev]:h-10 [&_.swiper-button-next]:bg-black/35 [&_.swiper-button-prev]:bg-black/35 [&_.swiper-button-next]:backdrop-blur-sm [&_.swiper-button-prev]:backdrop-blur-sm [&_.swiper-button-next]:rounded-full [&_.swiper-button-prev]:rounded-full [&_.swiper-button-next]:hover:bg-black/60 [&_.swiper-button-prev]:hover:bg-black/60 [&_.swiper-pagination-bullet]:bg-white/60 [&_.swiper-pagination-bullet-active]:!bg-brand-primary [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4"
>
{slides.map((slide, index) => {
const title = localizedFromRecord(slide, 'title', resolvedLang)
const subtitle = localizedFromRecord(slide, 'subtitle', resolvedLang)
const alt = localizedFromRecord(slide, 'alt', resolvedLang) || title || `Slide ${index + 1}`
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
const mobileImageUrl = typeof slide.mobileImageUrl === 'string' && slide.mobileImageUrl ? slide.mobileImageUrl : imageUrl
const linkUrl = typeof slide.linkUrl === 'string' ? slide.linkUrl : ''
const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? (slide.focalPoint as { x: string, y: string }) : undefined
if (!imageUrl) return null
const slideContent = (
<div className={`relative w-full ${getMobileAspectClass()} ${getAspectClass()} overflow-hidden`}>
{/* Desktop Image */}
<div className="hidden md:block absolute inset-0 w-full h-full">
<Image
src={imageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Mobile Image */}
<div className="block md:hidden absolute inset-0 w-full h-full">
<Image
src={mobileImageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Optional Text Overlay */}
{(title || subtitle || buttonText) && (
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
<div className="max-w-xl space-y-1.5 md:space-y-2">
{subtitle && <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-brand-accent drop-shadow-md">{subtitle}</p>}
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
{buttonText && (
<span className="inline-block mt-2 px-4 py-1.5 bg-brand-primary text-white text-xs font-bold rounded-full shadow-lg hover:bg-brand-primary-hover transition-all">
{buttonText}
</span>
)}
</div>
</div>
)}
</div>
)
return (
<SwiperSlide key={index}>
{linkUrl ? (
<Link href={linkUrl} className="block w-full h-full group cursor-pointer">
{slideContent}
</Link>
) : (
slideContent
)}
</SwiperSlide>
)
})}
</Swiper>
</section>
)
}
function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1888,32 +2012,36 @@ function ContactFormSection({ payload, lang }: { payload: Record<string, unknown
)
}
import { useEffect } from 'react'
import ScrollReveal from '../ScrollReveal'
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
const i18nLang = useLang()
// Use i18n.language reactively — switches immediately when user changes language
const lang = langProp ?? i18nLang
useEffect(() => {
const HERO_TYPES = new Set([
'HERO',
'HERO_V2',
'HERO_VIDEO',
'HERO_ARTICLE',
'HERO_MINIMAL',
'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL',
])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR')
if (!firstActiveSection && sections.length > 0 && sections.every(s => !s.isActive || s.type.toUpperCase() === 'ANNOUNCEMENT_BAR')) {
return
}
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()))
const HERO_TYPES = useMemo(() => new Set([
'HERO',
'HERO_V2',
'HERO_VIDEO',
'HERO_ARTICLE',
'HERO_MINIMAL',
'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL',
'HERO_CAROUSEL',
]), [])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR_MINIMAL')
const firstPayload = toRecord(firstActiveSection?.payload)
const defaultSpacing = firstActiveSection?.type.toUpperCase() === 'HERO_CAROUSEL' ? 'below_header' : 'under_header'
const firstHeaderSpacing = typeof firstPayload.headerSpacing === 'string' ? firstPayload.headerSpacing : defaultSpacing
const isHeroFirst = Boolean(
firstActiveSection &&
HERO_TYPES.has(firstActiveSection.type.toUpperCase()) &&
firstHeaderSpacing === 'under_header'
)
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'
document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false'
}
return () => {
@@ -1921,7 +2049,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
document.documentElement.dataset.heroFirst = 'false'
}
}
}, [sections])
}, [sections, isHeroFirst])
return (
<div className="flex w-full flex-col">
@@ -1930,10 +2058,13 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
const payload = toRecord(section.payload)
const type = section.type.toUpperCase()
const isFirstContent = section.id === firstActiveSection?.id
const needsTopSpacing = isFirstContent && !isHeroFirst
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
const rendered = (() => {
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
case 'HERO':
return (
<ScrollReveal key={section.id}>
@@ -1948,6 +2079,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
)
case 'HERO_VIDEO':
return <HeroVideoSection key={section.id} payload={payload} lang={lang} />
case 'HERO_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<HeroCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_MINIMAL':
return (
<ScrollReveal key={section.id}>
@@ -2124,7 +2261,18 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
}
return null
}
})}
})()
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
{rendered}
</div>
)
}
return rendered
})}
</div>
)
}