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