Files
homaroma/apps/website/src/components/dynamic/DynamicPageRenderer.tsx
T
2026-10-16 06:26:13 +07:00

2855 lines
125 KiB
TypeScript

"use client"
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, ArrowRight } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, DividerSectionPayload, PageSection, Product } from '@homaroma/types'
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
import ProductCard from '../ProductCard'
import ProductSwatchCard from '../ProductSwatchCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang'
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 PageDivider from '../PageDivider'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
type DynamicPageRendererProps = {
sections: PageSection[]
categories: Category[]
lang?: string // optional override; defaults to i18n.language
}
function categoryName(category: Category, lang: string) {
return resolveCategoryName(category, lang)
}
function getProducts(payload: Record<string, unknown>): Product[] {
return Array.isArray(payload.products) ? payload.products as Product[] : []
}
function getCategoriesFromPayload(payload: Record<string, unknown>): Category[] {
return Array.isArray(payload.categories) ? payload.categories as Category[] : []
}
function localizedValue(value: unknown, lang: string) {
return resolveLocalized(value as never, lang)
}
function optionalText(value: unknown, lang: string) {
if (Array.isArray(value)) return value.map(v => localizedValue(v, lang)).filter(Boolean).join(', ')
return localizedValue(value, lang)
}
function renderAccentTitle(text: string, accentClassName = 'ui-accent-text') {
if (!text.includes('[') || !text.includes(']')) return text
return text.split('[').map((part, index) => {
if (!part.includes(']')) return <span key={index}>{part}</span>
const [accent, rest] = part.split(']')
return (
<span key={index}>
<span className={accentClassName}>{accent}</span>
{rest}
</span>
)
})
}
export function getSectionPaddingClasses(
payload?: Record<string, unknown>,
fallback: 'normal' | 'none' | 'compact' | 'spacious' = 'normal',
) {
const padTop = (typeof payload?.paddingTop === 'string'
? payload.paddingTop
: (typeof payload?.sectionPadding === 'string' ? payload.sectionPadding : fallback)) as string
const padBottom = (typeof payload?.paddingBottom === 'string'
? payload.paddingBottom
: (typeof payload?.sectionPadding === 'string' ? payload.sectionPadding : fallback)) as string
const getPt = (p: string) => {
switch (p) {
case 'none': return 'pt-0'
case 'compact': return 'pt-6 md:pt-10'
case 'spacious': return 'pt-20 md:pt-28'
case 'normal': default: return 'pt-10 md:pt-16'
}
}
const getPb = (p: string) => {
switch (p) {
case 'none': return 'pb-0'
case 'compact': return 'pb-6 md:pb-10'
case 'spacious': return 'pb-20 md:pb-28'
case 'normal': default: return 'pb-10 md:pb-16'
}
}
return `px-6 ${getPt(padTop)} ${getPb(padBottom)}`
}
function AnnouncementBar({ 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 backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const speed = typeof payload.speed === 'number' ? payload.speed : 3000
if (!slides.length) return null
return (
<section
className={`announcement-bar absolute left-0 top-0 z-[60] w-full overflow-hidden px-4 py-3 text-center text-sm font-semibold tracking-wide ${backgroundColor ? '' : 'bg-text-main'} ${textColor ? '' : 'text-surface-card'}`}
style={{ backgroundColor, color: textColor }}
>
<Swiper
modules={[Autoplay]}
autoplay={{ delay: speed, disableOnInteraction: false }}
loop={slides.length > 1}
className="w-full select-none"
>
{slides.map((slide, index) => (
<SwiperSlide key={index} className="flex items-center justify-center">
<span className="inline-block truncate max-w-full">
{localizedFromRecord({ slide }, 'slide', resolvedLang)}
</span>
</SwiperSlide>
))}
</Swiper>
</section>
)
}
const getMobilePositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'justify-start items-start text-left pt-20 pb-12 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'top-center':
return {
wrapper: 'justify-start items-center text-center pt-20 pb-12 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
case 'top-right':
return {
wrapper: 'justify-start items-end text-right pt-20 pb-12 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'center-left':
return {
wrapper: 'justify-center items-start text-left py-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'center-right':
return {
wrapper: 'justify-center items-end text-right py-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'bottom-left':
return {
wrapper: 'justify-end items-start text-left pt-12 pb-16 pl-6 pr-6',
content: 'items-start text-left',
actions: 'items-start justify-start'
}
case 'bottom-center':
return {
wrapper: 'justify-end items-center text-center pt-12 pb-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
case 'bottom-right':
return {
wrapper: 'justify-end items-end text-right pt-12 pb-16 pr-6 pl-6',
content: 'items-end text-right',
actions: 'items-end justify-end'
}
case 'center':
default:
return {
wrapper: 'justify-center items-center text-center py-16 px-6',
content: 'items-center text-center',
actions: 'items-center justify-center'
}
}
}
const getDesktopPositionClasses = (pos: string) => {
switch (pos) {
case 'top-left':
return {
wrapper: 'md:justify-start md:items-start md:text-left md:pt-32 md:pb-12 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'top-center':
return {
wrapper: 'md:justify-start md:items-center md:text-center md:pt-32 md:pb-12 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
case 'top-right':
return {
wrapper: 'md:justify-start md:items-end md:text-right md:pt-32 md:pb-12 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'center-left':
return {
wrapper: 'md:justify-center md:items-start md:text-left md:py-16 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'center-right':
return {
wrapper: 'md:justify-center md:items-end md:text-right md:py-16 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'bottom-left':
return {
wrapper: 'md:justify-end md:items-start md:text-left md:pt-12 md:pb-24 md:pl-20 md:pr-8',
content: 'md:items-start md:text-left',
actions: 'md:items-start md:justify-start'
}
case 'bottom-center':
return {
wrapper: 'md:justify-end md:items-center md:text-center md:pt-12 md:pb-24 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
case 'bottom-right':
return {
wrapper: 'md:justify-end md:items-end md:text-right md:pt-12 md:pb-24 md:pr-20 md:pl-8',
content: 'md:items-end md:text-right',
actions: 'md:items-end md:justify-end'
}
case 'center':
default:
return {
wrapper: 'md:justify-center md:items-center md:text-center md:py-16 md:px-8',
content: 'md:items-center md:text-center',
actions: 'md:items-center md:justify-center'
}
}
}
function getHeroPositionClasses(mobilePos: string, desktopPos: string) {
const mClasses = getMobilePositionClasses(mobilePos)
const dClasses = getDesktopPositionClasses(desktopPos)
return {
wrapperClasses: `${mClasses.wrapper} ${dClasses.wrapper}`,
contentClasses: `${mClasses.content} ${dClasses.content}`,
actionsClasses: `${mClasses.actions} ${dClasses.actions}`,
}
}
function getHeroContainerWidthClasses(contentWidth?: string) {
switch (contentWidth) {
case 'full':
return 'w-full max-w-none px-6 md:px-12 lg:px-16'
case 'compact':
return 'w-full max-w-4xl mx-auto px-6'
case 'standard':
default:
return 'w-full max-w-7xl mx-auto px-6 md:px-10 lg:px-12'
}
}
export function getHeroTitleSizeClass(titleSize?: unknown): string {
switch (titleSize) {
case 'sm':
return 'ui-hero-title-sm'
case 'md':
return 'ui-hero-title-md'
case 'lg':
return 'ui-hero-title-lg'
case 'xl':
default:
return 'ui-hero-title-xl'
}
}
export function getTailwindHeroTitleClass(titleSize?: unknown, defaultSize = 'xl'): string {
const size = (titleSize || defaultSize) as string
switch (size) {
case 'sm':
return 'text-2xl sm:text-3xl md:text-4xl'
case 'md':
return 'text-3xl sm:text-4xl md:text-5xl'
case 'lg':
return 'text-4xl sm:text-5xl md:text-6xl'
case 'xl':
default:
return 'text-4xl sm:text-5xl md:text-6xl lg:text-7xl'
}
}
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const rawTagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const rawTitle = localizedFromRecord(payload, 'title', resolvedLang)
const rawDescription = localizedFromRecord(payload, 'description', resolvedLang)
const rawButtonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const titleSize = typeof payload.titleSize === 'string' ? payload.titleSize : 'xl'
const titleSizeClass = getHeroTitleSizeClass(titleSize)
const textWrapperWidthClass = titleSize === 'sm' || contentWidth === 'compact' ? 'max-w-xl' : titleSize === 'md' ? 'max-w-2xl' : 'max-w-3xl'
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
// If fields are explicitly defined as empty strings, respect them as empty
const hasTagline = payload.tagline !== undefined && payload.tagline !== null ? !!rawTagline?.trim() : true
const tagline = hasTagline ? (rawTagline || 'VIBRANT ELEGANCE') : ''
const hasTitle = payload.title !== undefined && payload.title !== null ? !!rawTitle?.trim() : true
const title = hasTitle ? (rawTitle || 'New Scent [Sanctuary]') : ''
const hasDescription = payload.description !== undefined && payload.description !== null ? !!rawDescription?.trim() : false
const description = hasDescription ? rawDescription : ''
const buttonText = rawButtonText !== undefined && rawButtonText !== null
? rawButtonText.trim()
: 'Explore Collection'
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
const containerClasses = getHeroContainerWidthClasses(contentWidth)
const overlayStyle = typeof payload.overlayStyle === 'string' ? payload.overlayStyle : 'gradient'
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 45
const renderOverlay = () => {
if (overlayStyle === 'none' || overlayOpacity === 0) {
return null
}
const opacityRatio = overlayOpacity / 100
const rgb = isDarkText ? '255, 255, 255' : '0, 0, 0'
if (overlayStyle === 'solid') {
return (
<div
className="absolute inset-0 transition-opacity duration-300"
style={{ backgroundColor: `rgba(${rgb}, ${opacityRatio})` }}
/>
)
}
// Gradient style (default): smooth vertical gradient scaled by opacity
const bottomAlpha = Math.min(0.95, opacityRatio * 1.85)
const midAlpha = opacityRatio
const topAlpha = Math.max(0, opacityRatio * 0.65)
return (
<div
className="absolute inset-0 transition-opacity duration-300"
style={{
background: `linear-gradient(to top, rgba(${rgb}, ${bottomAlpha}) 0%, rgba(${rgb}, ${midAlpha}) 50%, rgba(${rgb}, ${topAlpha}) 100%)`,
}}
/>
)
}
const aspectRatio = typeof payload.aspectRatio === 'string' ? payload.aspectRatio : undefined
const mobileAspectRatio = typeof payload.mobileAspectRatio === 'string' ? payload.mobileAspectRatio : undefined
const mobileImageUrl = typeof payload.mobileImageUrl === 'string' && payload.mobileImageUrl ? payload.mobileImageUrl : imageUrl
const isFullScreen = !aspectRatio || aspectRatio === 'full_screen'
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]'
case 'full_screen': return 'md:h-[90vh] md:aspect-[21/9]'
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]'
case 'full_screen': return 'min-h-[85vh]'
default: return 'aspect-[16/9]'
}
}
const sectionHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:h-[90vh] md:aspect-[21/9]'
: `${getMobileAspectClass()} ${getAspectClass()}`
const innerHeightClass = isFullScreen
? 'min-h-[85vh] sm:min-h-[90vh] md:min-h-full'
: 'h-full justify-center'
return (
<section className={`relative w-full ${sectionHeightClass} flex overflow-hidden ${isDarkText ? 'bg-surface-page' : 'bg-night-950'}`}>
<div className="absolute inset-0 w-full h-full z-0">
{/* Desktop Image */}
<div className={`w-full h-full ${mobileImageUrl !== imageUrl ? 'hidden md:block' : ''}`}>
<Image
src={imageUrl}
alt={title || ''}
fill
unoptimized
className="object-cover md:scale-[1.02]"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Mobile Image (if provided) */}
{mobileImageUrl !== imageUrl && (
<div className="block md:hidden w-full h-full relative">
<Image
src={mobileImageUrl}
alt={title || ''}
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
)}
{/* Dynamic Dark / Light Gradient / Solid Overlay */}
{renderOverlay()}
</div>
<div className={`relative z-10 ${innerHeightClass} flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`w-full ${textWrapperWidthClass} flex flex-col space-y-5 ${contentClasses}`}>
{tagline && (
<p className={`text-xs font-semibold uppercase tracking-[0.3em] drop-shadow-md md:text-sm ${isDarkText ? 'text-brand-primary' : 'text-brand-accent'}`}>
{tagline}
</p>
)}
{title && (
<h1 className={`ui-hero-title ${titleSizeClass} ${isDarkText ? '!text-text-main' : '!text-white'} drop-shadow-lg`}>
{renderAccentTitle(title, isDarkText ? 'text-brand-primary font-medium' : 'ui-accent-text font-medium')}
</h1>
)}
{description && (
<p className={`ui-hero-copy ${isDarkText ? '!text-text-main/80' : '!text-white/90'} drop-shadow-md !pb-2 ${contentClasses.includes('text-left') ? '!mx-0' : contentClasses.includes('text-right') ? '!ml-auto !mr-0' : '!mx-auto'}`}>
{description}
</p>
)}
<div className={`flex flex-col sm:flex-row gap-4 sm:gap-6 w-full ${actionsClasses} pt-1`}>
{buttonText && (
<Link
href={buttonLink}
className="ui-hero-button shadow-lg shadow-black/30"
>
{buttonText}
</Link>
)}
<a
href="#categories"
className={`group flex items-center gap-1.5 drop-shadow-md hover:underline ${isDarkText ? '!text-text-main hover:!text-brand-primary' : 'ui-hero-link'}`}
>
<span>{t('dynamic.heroLearnMore', { lng: resolvedLang })}</span>
<span className="transition-transform group-hover:translate-x-1">→</span>
</a>
</div>
</div>
</div>
</section>
)
}
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) && (() => {
const slideDesktopPos = typeof slide.buttonPosition === 'string' ? slide.buttonPosition : 'bottom-left'
const slideMobilePos = typeof slide.mobileButtonPosition === 'string' ? slide.mobileButtonPosition : slideDesktopPos
const slideWidth = typeof slide.contentWidth === 'string' ? slide.contentWidth : 'standard'
const slideColor = typeof slide.textColorScheme === 'string' ? slide.textColorScheme : 'light'
const isDarkSlide = slideColor === 'dark'
const { wrapperClasses: sWrap, contentClasses: sContent, actionsClasses: sActions } = getHeroPositionClasses(slideMobilePos, slideDesktopPos)
const sContainer = getHeroContainerWidthClasses(slideWidth)
return (
<div className={`absolute inset-0 z-10 flex flex-col py-6 md:py-10 ${isDarkSlide ? 'bg-gradient-to-t from-white/80 via-white/30 to-transparent' : 'bg-gradient-to-t from-black/70 via-black/20 to-transparent'} ${sWrap}`}>
<div className={`w-full ${sContainer} flex flex-col ${sContent}`}>
<div className={`space-y-1.5 md:space-y-2 flex flex-col ${sContent} ${slideWidth === 'full' ? 'max-w-2xl' : 'max-w-xl'}`}>
{subtitle && <p className={`text-[10px] md:text-xs uppercase font-bold tracking-widest drop-shadow-md ${isDarkSlide ? 'text-brand-primary' : 'text-brand-accent'}`}>{subtitle}</p>}
{title && <h3 className={`text-lg md:text-3xl font-bold drop-shadow-md leading-tight ${isDarkSlide ? 'text-text-main' : 'text-white'}`}>{renderAccentTitle(title)}</h3>}
{buttonText && (
<div className={`flex ${sActions}`}>
<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>
</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
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const secondaryButtonText = localizedFromRecord(payload, 'secondaryButtonText', resolvedLang)
const secondaryButtonLink = typeof payload.secondaryButtonLink === 'string' ? payload.secondaryButtonLink : ''
const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
const posterImageUrl = typeof payload.posterImageUrl === 'string' ? payload.posterImageUrl : undefined
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 40
const height = typeof payload.height === 'string' ? payload.height : 'large'
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const getHeightClass = (h: string) => {
switch (h) {
case 'full':
return 'h-screen'
case 'medium':
return 'h-[60vh] min-h-[400px]'
case 'large':
default:
return 'h-[85vh] min-h-[550px]'
}
}
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const { wrapperClasses, contentClasses, actionsClasses } = getHeroPositionClasses(mobilePosition, desktopPosition)
const containerClasses = getHeroContainerWidthClasses(contentWidth)
return (
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex ${isDarkText ? 'bg-surface-page text-text-main' : 'bg-[#0B0F17] text-white'}`}>
{videoUrl ? (
<video
autoPlay={payload.autoplay !== false}
muted={payload.muted !== false}
loop={payload.loop !== false}
playsInline
poster={posterImageUrl}
className="absolute inset-0 w-full h-full object-cover z-0"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
{posterImageUrl && (
<Image
src={posterImageUrl}
alt="Hero fallback"
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
)}
</video>
) : posterImageUrl ? (
<Image
src={posterImageUrl}
alt="Hero Poster"
fill
unoptimized
className="object-cover z-0"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
) : null}
<div className={`absolute inset-0 z-10 transition-opacity ${isDarkText ? 'bg-white' : 'bg-black'}`} style={{ opacity: overlayOpacity / 100 }}></div>
<div className={`relative z-20 py-16 h-full flex flex-col ${containerClasses} ${wrapperClasses}`}>
<div className={`flex flex-col space-y-6 ${payload.titleSize === 'sm' || contentWidth === 'compact' ? 'max-w-xl' : payload.titleSize === 'md' ? 'max-w-2xl' : 'max-w-3xl'} ${contentClasses}`}>
{tagline && (
<p className={`text-xs md:text-sm font-semibold uppercase tracking-[0.35em] drop-shadow-md ${isDarkText ? 'text-brand-primary' : 'text-brand-accent'}`}>
{tagline}
</p>
)}
{title && (
<h1 className={`font-serif ${getTailwindHeroTitleClass(payload.titleSize, 'xl')} font-light leading-tight tracking-wide drop-shadow-lg ${isDarkText ? 'text-text-main' : 'text-white'}`}>
{renderAccentTitle(title, isDarkText ? 'text-brand-primary font-medium' : undefined)}
</h1>
)}
{description && (
<p className={`max-w-2xl text-sm md:text-lg font-light leading-relaxed drop-shadow ${isDarkText ? 'text-text-main/80' : 'opacity-90'}`}>
{description}
</p>
)}
{(buttonText || secondaryButtonText) && (
<div className={`flex flex-wrap gap-4 pt-4 ${actionsClasses}`}>
{buttonText && buttonLink && (
<Link
href={buttonLink}
className={`px-8 py-3.5 font-medium text-xs uppercase tracking-widest transition-all shadow-lg rounded-sm ${
isDarkText
? 'bg-brand-primary text-white hover:bg-brand-primary-hover'
: 'bg-white text-neutral-900 hover:bg-brand-primary hover:text-white'
}`}
>
{buttonText}
</Link>
)}
{secondaryButtonText && secondaryButtonLink && (
<Link
href={secondaryButtonLink}
className={`px-8 py-3.5 bg-transparent border font-medium text-xs uppercase tracking-widest transition-all rounded-sm backdrop-blur-sm ${
isDarkText
? 'border-neutral-900/40 text-neutral-900 hover:bg-neutral-900/10 hover:border-neutral-900'
: 'border-white/40 text-white hover:bg-white/10 hover:border-white'
}`}
>
{secondaryButtonText}
</Link>
)}
</div>
)}
</div>
</div>
</section>
)
}
const ICON_MAP: Record<string, React.ElementType> = {
leaf: Leaf,
droplet: Droplet,
sparkles: Sparkles,
gift: Gift,
check: CheckCircle,
shield: ShieldCheck,
heart: Heart,
}
function UspBarSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const items = Array.isArray(payload.items) ? payload.items : []
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : 'var(--surface-page)'
if (items.length === 0) return null
return (
<section className="w-full py-8 border-t border-b border-line-primary/10" style={{ backgroundColor }}>
<div className="ui-section-shell">
<div className={`grid grid-cols-1 sm:grid-cols-2 md:grid-cols-${Math.min(items.length, 4)} gap-6 divide-y sm:divide-y-0 sm:divide-x divide-line-primary/20`}>
{items.slice(0, 4).map((item: any, idx: number) => {
const Icon = ICON_MAP[item.icon] || CheckCircle
return (
<div key={idx} className="flex items-start gap-4 px-4 pt-4 sm:pt-0 first:pt-0">
<div className="flex-shrink-0 text-brand-primary">
<Icon className="w-8 h-8" strokeWidth={1.5} />
</div>
<div>
<h3 className="text-sm md:text-base font-semibold text-text-main">
{localizedFromRecord(item, 'title', resolvedLang)}
</h3>
{item.subtitle && (
<p className="text-xs md:text-sm text-muted-text mt-1">
{localizedFromRecord(item, 'subtitle', resolvedLang)}
</p>
)}
</div>
</div>
)
})}
</div>
</div>
</section>
)
}
function AnnouncementBarMinimal({ 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 backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : '#0F1713'
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : '#DCE6E0'
const speed = typeof payload.speed === 'number' ? payload.speed : 4000
if (!slides.length) return null
return (
<section
className="announcement-bar-minimal absolute left-0 top-0 z-[60] w-full overflow-hidden px-4 py-2 text-center text-[11px] md:text-xs font-medium tracking-[0.2em] uppercase select-none transition-colors"
style={{ backgroundColor, color: textColor }}
>
<Swiper
modules={[Autoplay]}
autoplay={{ delay: speed, disableOnInteraction: false }}
loop={slides.length > 1}
className="w-full"
>
{slides.map((slide, index) => (
<SwiperSlide key={index} className="flex items-center justify-center">
<span className="inline-block truncate max-w-full">
{localizedFromRecord({ slide }, 'slide', resolvedLang)}
</span>
</SwiperSlide>
))}
</Swiper>
</section>
)
}
function HeroMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 20
const align = typeof payload.contentAlignment === 'string' ? payload.contentAlignment : 'center'
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const alignClass = align === 'left' ? 'text-left items-start' : align === 'right' ? 'text-right items-end ml-auto' : 'text-center items-center mx-auto'
return (
<section className="relative w-full h-[75vh] min-h-[500px] md:h-[85vh] bg-surface-page overflow-hidden flex items-center justify-center">
<Image
src={imageUrl}
alt={title || 'Hero'}
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
<div className="absolute inset-0 bg-black transition-opacity pointer-events-none" style={{ opacity: overlayOpacity / 100 }} />
<div className="relative z-10 w-full max-w-5xl mx-auto px-6 py-12 flex flex-col justify-center">
<div className={`flex flex-col gap-4 max-w-3xl ${alignClass}`}>
{tagline && (
<span className="text-xs md:text-sm font-medium uppercase tracking-[0.3em] text-brand-accent drop-shadow-md">
{tagline}
</span>
)}
{title && (
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-light text-white leading-tight tracking-wide drop-shadow-md">
{renderAccentTitle(title, 'text-brand-accent font-medium')}
</h1>
)}
</div>
</div>
</section>
)
}
function HeroSplitMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/oil_ozone.png'
const imagePosition = payload.imagePosition === 'left' ? 'left' : 'right'
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
return (
<section className="w-full py-16 md:py-24 bg-surface-page" style={{ backgroundColor }}>
<div className="max-w-6xl mx-auto px-6 md:px-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-16 items-center">
<div className={`flex flex-col justify-center gap-6 ${imagePosition === 'left' ? 'md:order-2' : 'md:order-1'}`}>
{title && (
<h2 className="font-serif text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-light text-text-main leading-snug tracking-wide">
{renderAccentTitle(title, 'text-brand-primary font-normal')}
</h2>
)}
{subtitle && (
<p className="text-sm md:text-base text-muted-text font-light leading-relaxed max-w-md">
{subtitle}
</p>
)}
</div>
<div className={`relative aspect-[3/4] w-full max-w-md mx-auto overflow-hidden rounded-sm bg-surface-card ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}>
<Image
src={imageUrl}
alt={title || 'Editorial'}
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
</div>
</div>
</section>
)
}
function HeroVideoMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
const posterImageUrl = typeof payload.posterImageUrl === 'string' ? payload.posterImageUrl : undefined
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 25
const height = typeof payload.height === 'string' ? payload.height : 'large'
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const getHeightClass = (h: string) => {
switch (h) {
case 'full':
return 'h-screen'
case 'medium':
return 'h-[55vh] min-h-[400px]'
case 'large':
default:
return 'h-[80vh] min-h-[500px]'
}
}
return (
<section className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center bg-[#0B0F17] text-white`}>
{videoUrl ? (
<video
autoPlay
muted
loop
playsInline
poster={posterImageUrl}
className="absolute inset-0 w-full h-full object-cover z-0"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
</video>
) : posterImageUrl ? (
<Image
src={posterImageUrl}
alt="Hero Poster"
fill
unoptimized
className="object-cover z-0"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
) : null}
<div className="absolute inset-0 z-10 bg-black transition-opacity pointer-events-none" style={{ opacity: overlayOpacity / 100 }} />
<div className="relative z-20 mx-auto px-6 py-12 w-full max-w-4xl text-center flex flex-col items-center justify-center gap-4">
{tagline && (
<p className="text-xs md:text-sm font-medium uppercase tracking-[0.35em] text-white/80 drop-shadow-md">
{tagline}
</p>
)}
{title && (
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-light leading-tight tracking-wide drop-shadow-md text-white">
{renderAccentTitle(title)}
</h1>
)}
</div>
</section>
)
}
function BannerMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' && payload.imageUrl ? payload.imageUrl : undefined
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : undefined
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const inner = (
<div
className={`relative w-full py-12 md:py-16 px-6 text-center overflow-hidden flex flex-col items-center justify-center gap-2 ${!imageUrl && !backgroundColor ? 'bg-surface-card' : ''}`}
style={{ backgroundColor, color: textColor }}
>
{imageUrl && (
<>
<Image
src={imageUrl}
alt={title || 'Banner'}
fill
unoptimized
className="object-cover z-0 opacity-40"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
<div className="absolute inset-0 bg-black/20 z-0" />
</>
)}
<div className="relative z-10 max-w-3xl mx-auto space-y-2">
{title && (
<h3 className="font-serif text-xl sm:text-2xl md:text-3xl font-light tracking-wide text-inherit">
{renderAccentTitle(title)}
</h3>
)}
{subtitle && (
<p className="text-xs sm:text-sm opacity-80 font-light tracking-wider max-w-xl mx-auto">
{subtitle}
</p>
)}
</div>
</div>
)
return (
<section className="w-full bg-surface-page">
{linkUrl ? <Link href={linkUrl} className="block group">{inner}</Link> : inner}
</section>
)
}
function UspMinimalSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const items = Array.isArray(payload.items) ? payload.items : []
const separator = typeof payload.separator === 'string' ? payload.separator : 'dot'
const backgroundColor = typeof payload.backgroundColor === 'string' && payload.backgroundColor ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' && payload.textColor ? payload.textColor : undefined
if (items.length === 0) return null
const sepSymbol = separator === 'slash' ? '/' : separator === 'line' ? '|' : '·'
return (
<section className="w-full py-6 border-t border-b border-line-primary/10 bg-surface-page" style={{ backgroundColor, color: textColor }}>
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs sm:text-sm font-medium uppercase tracking-widest text-inherit/90">
{items.map((item: any, idx: number) => (
<span key={idx} className="inline-flex items-center gap-x-4">
{idx > 0 && <span className="opacity-40 font-light select-none">{sepSymbol}</span>}
<span>{localizedFromRecord({ item }, 'item', resolvedLang)}</span>
</span>
))}
</div>
</div>
</section>
)
}
function HeroV2Section({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const primaryBtnText = localizedFromRecord(payload, 'primaryButtonText', resolvedLang)
const primaryBtnLink = typeof payload.primaryButtonLink === 'string' ? payload.primaryButtonLink : undefined
const secondaryBtnText = localizedFromRecord(payload, 'secondaryButtonText', resolvedLang)
const secondaryBtnLink = typeof payload.secondaryButtonLink === 'string' ? payload.secondaryButtonLink : undefined
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const align = typeof payload.contentAlignment === 'string' ? payload.contentAlignment : 'left'
const showOrnament = typeof payload.showOrnament === 'boolean' ? payload.showOrnament : true
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
// Alignment classes
const alignClass = align === 'center' ? 'text-center items-center mx-auto' : align === 'right' ? 'text-right items-end ml-auto' : 'text-left items-start'
return (
<section className="relative w-full flex flex-col md:block md:h-[80vh] md:min-h-[600px] bg-surface-page overflow-hidden">
<div className="relative w-full h-[50vh] min-h-[400px] md:absolute md:inset-0 md:h-full">
<Image
src={imageUrl}
alt={title || 'Hero'}
fill
unoptimized
className="object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 py-12 md:py-0 md:h-full md:flex md:items-center md:px-12">
<div className={`max-w-xl flex flex-col gap-6 ${alignClass}`}>
{tagline && (
<div className="flex items-center gap-4">
{showOrnament && align !== 'right' && <div className="w-8 h-[1px] bg-brand-primary" />}
<span className="text-sm font-semibold tracking-[0.2em] uppercase text-brand-primary">
{tagline}
</span>
{showOrnament && align !== 'left' && <div className="w-8 h-[1px] bg-brand-primary" />}
</div>
)}
<h1 className={`${getTailwindHeroTitleClass(payload.titleSize, 'lg')} font-serif text-text-main leading-tight`}>
{renderAccentTitle(title || '', 'text-brand-primary')}
</h1>
{showOrnament && (
<div className="flex justify-center items-center text-brand-primary my-2">
<Sparkles className="w-5 h-5" />
</div>
)}
{description && (
<p className="text-base md:text-lg text-text-main/80 leading-relaxed max-w-md">
{description}
</p>
)}
<div className={`flex flex-col sm:flex-row items-center gap-4 mt-4 w-full sm:w-auto ${align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start'}`}>
{primaryBtnText && primaryBtnLink && (
<Link
href={primaryBtnLink}
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-brand-primary text-white font-medium rounded-md hover:bg-brand-primary-hover transition-colors w-full sm:w-auto"
>
{primaryBtnText}
<ChevronRight className="w-4 h-4" />
</Link>
)}
{secondaryBtnText && secondaryBtnLink && (
<Link
href={secondaryBtnLink}
className="inline-flex items-center justify-center gap-2 px-8 py-3.5 bg-transparent border border-brand-primary text-brand-primary font-medium rounded-md hover:bg-brand-primary/5 transition-colors w-full sm:w-auto"
>
{secondaryBtnText}
<ChevronRight className="w-4 h-4" />
</Link>
)}
</div>
</div>
</div>
</section>
)
}
function CategoriesSection({
payload,
categories,
lang,
}: {
payload: Record<string, unknown>
categories: Category[]
lang: string
}) {
const i18nLang = useLang()
const { t } = useTranslation()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Our Collections'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const categoryIds = Array.isArray(payload.categoryIds) ? payload.categoryIds.map(Number) : []
const payloadCategories = getCategoriesFromPayload(payload)
const sourceCategories = payloadCategories.length ? payloadCategories : categories
const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories
return (
<section id="categories" className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="ui-section-shell space-y-14">
<div className="mx-auto max-w-2xl space-y-4 text-center">
<h2 className="ui-section-title">{title}</h2>
{subtitle && <p className="ui-section-subtitle">{subtitle}</p>}
</div>
<div className="grid grid-cols-2 gap-5 md:grid-cols-5">
{displayed.map((category) => {
const name = categoryName(category, resolvedLang)
return (
<Link
key={category.id}
href={`/category/${category.id}`}
className="group relative h-72 overflow-hidden rounded-2xl shadow-card transition hover:-translate-y-1 hover:shadow-card-hover"
>
<Image
src={resolveCategoryMediaUrl(category, 'card_portrait', getCategoryImage(name))}
alt={name}
fill
unoptimized
className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
<div className="absolute bottom-5 left-5 right-5 z-20 text-white">
<span className="block text-lg font-semibold">{name}</span>
{category._count && (
<span className="text-sm text-white/90">
{category._count.products} {t('dynamic.products', { lng: resolvedLang })}
</span>
)}
</div>
</Link>
)
})}
</div>
</div>
</section>
)
}
function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const layout = typeof payload.layout === 'string' ? payload.layout : 'grid'
const cardStyle = typeof payload.cardStyle === 'string' ? payload.cardStyle : 'standard'
const viewAllLink = typeof payload.viewAllLink === 'string' && payload.viewAllLink.trim() ? payload.viewAllLink.trim() : '/shop'
const viewAllText = localizedFromRecord(payload, 'viewAllText', resolvedLang) || t('dynamic.viewAll', { lng: resolvedLang }) || 'View All'
const badgeText = localizedFromRecord(payload, 'badgeText', resolvedLang)
const products = getProducts(payload)
const carouselRef = useRef<HTMLDivElement>(null)
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' })
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' })
const isMinimal = cardStyle === 'minimal' || cardStyle === 'swatch' || cardStyle === 'minimal_swatch'
const containerWidth = typeof payload.containerWidth === 'string' ? payload.containerWidth : 'standard'
const cardSize = typeof payload.cardSize === 'string' ? payload.cardSize : 'medium'
const getContainerWidthClass = (width: string) => {
switch (width) {
case 'full':
return 'w-full max-w-none'
case 'wide':
return 'w-full max-w-[1600px] mx-auto'
case 'standard':
default:
return 'w-full max-w-7xl mx-auto'
}
}
const getCarouselCardWidth = () => {
switch (cardSize) {
case 'compact':
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start'
case 'large':
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start'
case 'medium':
default:
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start'
}
}
const getGridColsClass = () => {
switch (cardSize) {
case 'compact':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6'
case 'large':
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'
case 'medium':
default:
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}
return (
<section className={`overflow-hidden bg-surface-page px-4 sm:px-6 md:px-8 ${getSectionPaddingClasses(payload)}`}>
<div className={`${getContainerWidthClass(containerWidth)} space-y-8`}>
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-line-primary/10 pb-5">
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
<h2 className={isMinimal ? "text-2xl md:text-3xl font-serif text-text-main tracking-tight" : "ui-section-title"}>
{title}
</h2>
{subtitle && (
<p className={isMinimal ? "text-xs md:text-sm text-muted-text font-normal mt-1 md:mt-0" : "mt-2 ui-section-subtitle"}>
{subtitle}
</p>
)}
</div>
<Link href={viewAllLink} className="inline-flex items-center gap-1.5 text-xs font-semibold text-text-main hover:text-brand-primary transition-colors underline-offset-4 hover:underline self-start md:self-auto">
<span>{viewAllText}</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
{products.length ? (
layout === 'carousel' ? (
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
<button
onClick={scrollLeft}
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-4 border border-line-primary/15"
aria-label="Scroll left"
>
<ChevronLeft className="h-5 w-5" />
</button>
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 md:gap-5 pb-4">
{products.map((product) => (
<div
key={product.id}
className={getCarouselCardWidth()}
>
<ProductCard product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
</div>
))}
</div>
<button
onClick={scrollRight}
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-4 border border-line-primary/15"
aria-label="Scroll right"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
) : (
<div className={getGridColsClass()}>
{products.map((product) => (
<ProductCard key={product.id} product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
))}
</div>
)
) : (
<p className="ui-section-card bg-surface-card p-8 text-center text-muted-text">
{t('dynamic.noProducts', { lng: resolvedLang })}
</p>
)}
</div>
</section>
)
}
function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
const desktopPosition = typeof payload.buttonPosition === 'string' ? payload.buttonPosition : 'center-left'
const mobilePosition = typeof payload.mobileButtonPosition === 'string' ? payload.mobileButtonPosition : desktopPosition
const contentWidth = typeof payload.contentWidth === 'string' ? payload.contentWidth : 'standard'
const textColorScheme = typeof payload.textColorScheme === 'string' ? payload.textColorScheme : 'light'
const isDarkText = textColorScheme === 'dark'
const { wrapperClasses: bWrap, contentClasses: bContent, actionsClasses: bActions } = getHeroPositionClasses(mobilePosition, desktopPosition)
const shellWidthClass = contentWidth === 'full'
? '!max-w-none !rounded-none md:!rounded-3xl mx-4 md:mx-8'
: contentWidth === 'compact'
? '!max-w-4xl'
: '!max-w-7xl'
const content = (
<div className={`ui-banner-shell flex flex-col ${shellWidthClass} ${bWrap}`}>
<Image
src={imageUrl}
alt={title || 'Banner'}
fill
unoptimized
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
<div className={isDarkText ? "absolute inset-0 bg-white/40 backdrop-blur-[1px]" : "ui-banner-overlay"} />
<div className={`ui-banner-content flex flex-col space-y-4 ${payload.titleSize === 'sm' || contentWidth === 'compact' ? 'max-w-xl' : 'max-w-2xl'} ${bContent}`}>
{title && (
<h2 className={`${payload.titleSize === 'sm' ? 'text-2xl md:text-3xl' : payload.titleSize === 'md' ? 'text-2xl md:text-4xl' : payload.titleSize === 'lg' ? 'text-3xl md:text-5xl' : 'text-3xl md:text-5xl font-semibold'} drop-shadow-md ${isDarkText ? '!text-text-main' : 'text-brand-primary'}`}>
{title}
</h2>
)}
{description && <p className={`drop-shadow-sm ${isDarkText ? '!text-text-main/80' : 'text-surface-page/90'}`}>{description}</p>}
{buttonText && (
<div className={`flex ${bActions}`}>
<span className={`inline-flex cursor-pointer rounded-full px-6 py-3 font-semibold transition-colors ${
isDarkText
? 'bg-brand-primary text-white hover:bg-brand-primary-hover shadow-md'
: 'bg-surface-card text-text-main hover:bg-brand-primary hover:text-surface-card'
}`}>{buttonText}</span>
</div>
)}
</div>
</div>
)
return <section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>{linkUrl ? <Link href={linkUrl}>{content}</Link> : content}</section>
}
function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const content = localizedFromRecord(payload, 'content', resolvedLang)
const alignClass = payload.textAlign === 'center'
? 'text-center'
: payload.textAlign === 'right'
? 'text-right'
: 'text-left'
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className={`mx-auto max-w-4xl space-y-6 ${alignClass}`}>
{title && <h2 className="text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
{content && <div className="leading-relaxed text-muted-text" dangerouslySetInnerHTML={{ __html: content }} />}
</div>
</section>
)
}
function ArticleHeaderSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const category = localizedFromRecord(payload, 'category', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const imageAlt = localizedFromRecord(payload, 'imageAlt', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const publishDate = typeof payload.publishDate === 'string' ? payload.publishDate : ''
if (!title) return null
return (
<header className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-4xl text-center">
{category && <p className="mb-4 text-sm font-semibold uppercase text-brand-primary">{category}</p>}
<h1 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl">{title}</h1>
{subtitle && <p className="mx-auto mt-6 max-w-3xl text-lg leading-8 text-muted-text md:text-xl">{subtitle}</p>}
{(author || publishDate) && (
<p className="mt-6 text-sm text-muted-text">
{[author, publishDate].filter(Boolean).join(' · ')}
</p>
)}
</div>
{imageUrl && (
<div className="relative mt-10 aspect-[16/9] w-full overflow-hidden rounded-lg bg-surface-card">
<Image src={imageUrl} alt={imageAlt || title} fill unoptimized priority sizes="100vw" className="object-cover" />
</div>
)}
</div>
</header>
)
}
function ArticleMediaSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const heading = localizedFromRecord(payload, 'heading', resolvedLang)
const body = localizedFromRecord(payload, 'body', resolvedLang)
const imageAlt = localizedFromRecord(payload, 'imageAlt', resolvedLang)
const imageCaption = localizedFromRecord(payload, 'imageCaption', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'left' || payload.imagePosition === 'top'
? payload.imagePosition
: 'right'
const widthClass = payload.width === 'narrow'
? 'max-w-3xl'
: payload.width === 'wide'
? 'max-w-7xl'
: 'max-w-6xl'
const backgroundClass = payload.background === 'muted'
? 'bg-surface-card'
: 'bg-surface-page'
if (!body) return null
const copy = (
<div className="flex min-w-0 flex-col justify-center">
{heading && (
<h2 className="mb-5 text-3xl font-semibold text-text-main md:text-4xl">
{heading}
</h2>
)}
<p className="whitespace-pre-line text-base leading-8 text-muted-text md:text-lg">
{body}
</p>
</div>
)
const media = imageUrl ? (
<figure className="min-w-0">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-lg bg-surface-page">
<Image
src={imageUrl}
alt={imageAlt || heading || ''}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
{imageCaption && (
<figcaption className="mt-3 text-sm leading-6 text-muted-text">
{imageCaption}
</figcaption>
)}
</figure>
) : null
return (
<section className={`${backgroundClass} ${getSectionPaddingClasses(payload)}`}>
<div className={`mx-auto ${widthClass}`}>
{!media ? copy : imagePosition === 'top' ? (
<div className="space-y-8">
{media}
{copy}
</div>
) : (
<div className="grid items-center gap-8 md:grid-cols-2 md:gap-12">
<div className={imagePosition === 'right' ? 'md:order-2' : undefined}>
{media}
</div>
<div className={imagePosition === 'right' ? 'md:order-1' : undefined}>
{copy}
</div>
</div>
)}
</div>
</section>
)
}
function ArticleGallerySection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const images = Array.isArray(payload.images) ? payload.images : []
const featured = payload.layout === 'featured'
if (images.length < 2) return null
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-7xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<div className={featured ? 'grid gap-4 md:grid-cols-2' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'}>
{images.map((image, index) => {
const item = toRecord(image)
const imageUrl = typeof item.imageUrl === 'string' ? item.imageUrl : ''
const alt = localizedFromRecord(item, 'alt', resolvedLang)
const caption = localizedFromRecord(item, 'caption', resolvedLang)
if (!imageUrl) return null
return (
<figure key={`${imageUrl}-${index}`} className={featured && index === 0 ? 'md:row-span-2' : undefined}>
<div className={`relative w-full overflow-hidden rounded-lg bg-surface-card ${featured && index === 0 ? 'aspect-[4/5] md:h-full md:min-h-[32rem]' : 'aspect-[4/3]'}`}>
<Image src={imageUrl} alt={alt} fill unoptimized sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" />
</div>
{caption && <figcaption className="mt-3 text-sm leading-6 text-muted-text">{caption}</figcaption>}
</figure>
)
})}
</div>
</div>
</section>
)
}
function ArticleFactsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const items = Array.isArray(payload.items) ? payload.items : []
if (items.length === 0) return null
return (
<section className={`bg-surface-card ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-6xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<dl className="grid gap-x-10 gap-y-8 md:grid-cols-2">
{items.map((item, index) => {
const record = toRecord(item)
const itemTitle = localizedFromRecord(record, 'title', resolvedLang)
const description = localizedFromRecord(record, 'description', resolvedLang)
return (
<div key={index} className="border-t border-line-primary/20 pt-5">
<dt className="text-lg font-semibold text-text-main">{itemTitle}</dt>
{description && <dd className="mt-2 leading-7 text-muted-text">{description}</dd>}
</div>
)
})}
</dl>
</div>
</section>
)
}
function getVideoEmbedUrl(videoUrl: string) {
try {
const url = new URL(videoUrl)
if (url.hostname === 'youtu.be') return `https://www.youtube.com/embed/${url.pathname.slice(1)}`
if (url.hostname === 'youtube.com' || url.hostname.endsWith('.youtube.com')) {
const id = url.searchParams.get('v')
return id ? `https://www.youtube.com/embed/${id}` : ''
}
if (url.hostname === 'vimeo.com' || url.hostname.endsWith('.vimeo.com')) {
const id = url.pathname.split('/').filter(Boolean).pop()
return id ? `https://player.vimeo.com/video/${id}` : ''
}
} catch {
return ''
}
return ''
}
function ArticleVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const caption = localizedFromRecord(payload, 'caption', resolvedLang)
const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
const posterUrl = typeof payload.posterUrl === 'string' ? payload.posterUrl : undefined
const embedUrl = getVideoEmbedUrl(videoUrl)
const aspectClass = payload.aspectRatio === '4:3' ? 'aspect-[4/3]' : payload.aspectRatio === '1:1' ? 'aspect-square' : 'aspect-video'
if (!videoUrl) return null
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-6xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<div className={`${aspectClass} overflow-hidden rounded-lg bg-text-main`}>
{embedUrl ? (
<iframe
src={embedUrl}
title={title || caption || 'Article video'}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<video src={videoUrl} poster={posterUrl} controls preload="metadata" className="h-full w-full object-contain" />
)}
</div>
{caption && <p className="mt-3 text-sm leading-6 text-muted-text">{caption}</p>}
</div>
</section>
)
}
function ArticleReferencesSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const items = Array.isArray(payload.items) ? payload.items : []
if (items.length === 0) return null
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-4xl">
<h2 className="mb-6 text-2xl font-semibold text-text-main">{title || 'References'}</h2>
<ol className="list-decimal space-y-4 pl-5 text-muted-text">
{items.map((item, index) => {
const record = toRecord(item)
const itemTitle = localizedFromRecord(record, 'title', resolvedLang)
const note = localizedFromRecord(record, 'note', resolvedLang)
const url = typeof record.url === 'string' ? record.url : ''
return (
<li key={index} className="pl-2">
{url ? (
<a href={url} target="_blank" rel="noreferrer" className="font-medium text-text-main underline decoration-line-primary underline-offset-4">
{itemTitle}
</a>
) : <span className="font-medium text-text-main">{itemTitle}</span>}
{note && <p className="mt-1 text-sm leading-6">{note}</p>}
</li>
)
})}
</ol>
</div>
</section>
)
}
function ArticleTableSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const note = localizedFromRecord(payload, 'note', resolvedLang)
const columns = Array.isArray(payload.columns) ? payload.columns : []
const rows = Array.isArray(payload.rows) ? payload.rows : []
if (columns.length < 2 || rows.length === 0) return null
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-6xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<div className="overflow-x-auto rounded-lg border border-line-primary/20">
<table className="w-full min-w-[42rem] border-collapse text-left">
<thead className="bg-surface-card">
<tr>
<th className="px-5 py-4 text-sm font-semibold text-text-main" scope="col" />
{columns.map((column, index) => (
<th key={index} className="px-5 py-4 text-sm font-semibold text-text-main" scope="col">
{localizedValue(column, resolvedLang)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const record = toRecord(row)
const cells = Array.isArray(record.cells) ? record.cells : []
return (
<tr key={rowIndex} className="border-t border-line-primary/20">
<th className="px-5 py-4 font-semibold text-text-main" scope="row">
{localizedFromRecord(record, 'label', resolvedLang)}
</th>
{cells.map((cell, cellIndex) => (
<td key={cellIndex} className="px-5 py-4 leading-7 text-muted-text">
{localizedValue(cell, resolvedLang)}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
{note && <p className="mt-4 text-sm leading-6 text-muted-text">{note}</p>}
</div>
</section>
)
}
function ArticleDownloadSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const files = Array.isArray(payload.files) ? payload.files : []
if (files.length === 0) return null
return (
<section className={`bg-surface-card ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-5xl">
{title && <h2 className="text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
{description && <p className="mt-4 max-w-3xl leading-7 text-muted-text">{description}</p>}
<div className="mt-8 divide-y divide-line-primary/20 border-y border-line-primary/20">
{files.map((file, index) => {
const record = toRecord(file)
const fileTitle = localizedFromRecord(record, 'title', resolvedLang)
const url = typeof record.url === 'string' ? record.url : ''
const meta = [record.fileType, record.fileSize].filter((value) => typeof value === 'string' && value).join(' · ')
if (!url) return null
return (
<a key={index} href={url} target="_blank" rel="noreferrer" className="flex items-center gap-4 py-5 text-text-main transition-opacity hover:opacity-70">
<FileText className="h-6 w-6 shrink-0 text-brand-primary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block font-semibold">{fileTitle}</span>
{meta && <span className="mt-1 block text-sm text-muted-text">{meta}</span>}
</span>
<Download className="h-5 w-5 shrink-0" aria-hidden="true" />
</a>
)
})}
</div>
</div>
</section>
)
}
function ArticleCtaSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const primaryText = localizedFromRecord(payload, 'primaryText', resolvedLang)
const secondaryText = localizedFromRecord(payload, 'secondaryText', resolvedLang)
const primaryLink = typeof payload.primaryLink === 'string' ? payload.primaryLink : ''
const secondaryLink = typeof payload.secondaryLink === 'string' ? payload.secondaryLink : ''
const centered = payload.alignment !== 'left'
if (!title || !primaryText || !primaryLink) return null
const primaryClass = "inline-flex min-h-12 items-center justify-center rounded-lg bg-brand-primary px-6 py-3 font-semibold text-surface-card transition-opacity hover:opacity-85"
const secondaryClass = "inline-flex min-h-12 items-center justify-center rounded-lg border border-line-primary px-6 py-3 font-semibold text-text-main transition-colors hover:bg-surface-page"
return (
<section className={`bg-surface-card ${getSectionPaddingClasses(payload)}`}>
<div className={`mx-auto max-w-5xl ${centered ? 'text-center' : 'text-left'}`}>
<h2 className="text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>
{description && <p className={`mt-4 max-w-2xl leading-7 text-muted-text ${centered ? 'mx-auto' : ''}`}>{description}</p>}
<div className={`mt-8 flex flex-wrap gap-3 ${centered ? 'justify-center' : 'justify-start'}`}>
{primaryLink.startsWith('/') ? <Link href={primaryLink} className={primaryClass}>{primaryText}</Link> : <a href={primaryLink} className={primaryClass}>{primaryText}</a>}
{secondaryText && secondaryLink && (
secondaryLink.startsWith('/') ? <Link href={secondaryLink} className={secondaryClass}>{secondaryText}</Link> : <a href={secondaryLink} className={secondaryClass}>{secondaryText}</a>
)}
</div>
</div>
</section>
)
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Stay in touch'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang) || 'Subscribe'
const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com'
return (
<section className="ui-section-dark">
<div className="mx-auto grid max-w-5xl gap-8 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-3">
<h2 className="text-3xl font-semibold text-surface-card md:text-4xl">{title}</h2>
{description && <p className="max-w-2xl text-surface-card">{description}</p>}
</div>
<form className="flex flex-col gap-3 sm:flex-row">
<input className="ui-form-shell min-w-0 rounded-full" placeholder={placeholder} type="email" />
<button className="ui-button-brand rounded-full" type="button">
{buttonText}
</button>
</form>
</div>
</section>
)
}
function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left'
const imageAspectRatio = typeof payload.imageAspectRatio === 'string' ? payload.imageAspectRatio : 'square'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const getAspectClass = (ratio: string) => {
switch (ratio) {
case 'portrait':
return 'aspect-[3/4]'
case 'tall':
return 'aspect-[2/3]'
case 'auto':
return 'aspect-auto'
case 'square':
default:
return 'aspect-square'
}
}
return (
<section className={`overflow-hidden bg-surface-page ${getSectionPaddingClasses(payload)}`} style={{ backgroundColor, color: textColor }}>
<div className="mx-auto grid max-w-7xl gap-12 md:grid-cols-2 md:items-center">
{imageUrl && (
<div className={`group relative ${getAspectClass(imageAspectRatio)} overflow-hidden rounded-3xl shadow-card ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}>
<Image
src={imageUrl}
alt={title || 'Editorial'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
)}
<div className={`space-y-6 ${imagePosition === 'left' ? 'md:order-2' : 'md:order-1'}`}>
{title && (
<h2 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl" style={{ color: textColor }}>
{renderAccentTitle(title)}
</h2>
)}
<div className="h-px w-14 bg-brand-primary" />
{description && <p className="whitespace-pre-line text-base leading-relaxed text-muted-text md:text-lg" style={{ color: textColor }}>{description}</p>}
{buttonText && buttonLink && (
<Link href={buttonLink} className="ui-section-link inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-brand-primary hover:text-surface-card">
{buttonText}
</Link>
)}
</div>
</div>
</section>
)
}
function FeaturedTilesSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const columns = typeof payload.columns === 'number' ? payload.columns : 2
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const tiles = Array.isArray(payload.tiles) ? payload.tiles.map(toRecord) : []
const layoutPreset = typeof payload.layoutPreset === 'string' ? payload.layoutPreset : undefined
const textAlign = typeof payload.textAlign === 'string' ? payload.textAlign : (layoutPreset === 'editorial' ? 'left' : 'center')
const ctaStyle = typeof payload.ctaStyle === 'string' ? payload.ctaStyle : (layoutPreset === 'editorial' ? 'arrow_link' : 'pill_button')
const sectionAspect = typeof payload.imageAspectRatio === 'string' ? payload.imageAspectRatio : (layoutPreset === 'editorial' ? 'landscape' : undefined)
const imageRounded = typeof payload.imageRounded === 'string' ? payload.imageRounded : (layoutPreset === 'editorial' ? 'md' : '3xl')
const containerWidth = typeof payload.containerWidth === 'string' ? payload.containerWidth : 'standard'
const getContainerWidthClass = (width: string) => {
switch (width) {
case 'full':
return 'w-full max-w-none'
case 'wide':
return 'w-full max-w-[1600px] mx-auto'
case 'standard':
default:
return 'w-full max-w-7xl mx-auto'
}
}
const getColsClass = (cols: number) => {
switch (cols) {
case 3:
return 'grid-cols-1 md:grid-cols-3'
case 4:
return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
case 2:
default:
return 'grid-cols-1 md:grid-cols-2'
}
}
const getAspectClass = (ratio?: string) => {
const r = ratio || sectionAspect || 'square'
switch (r) {
case 'landscape':
return 'aspect-[16/9]'
case 'wide':
return 'aspect-[4/3]'
case 'portrait':
return 'aspect-[3/4]'
case 'tall':
return 'aspect-[2/3]'
case 'auto':
return 'aspect-auto'
case 'square':
default:
return 'aspect-square'
}
}
const getRoundedClass = (rounded?: string) => {
switch (rounded) {
case 'none':
return 'rounded-none'
case 'sm':
return 'rounded-md'
case 'md':
return 'rounded-xl'
case '2xl':
return 'rounded-2xl'
case 'full':
return 'rounded-full'
case '3xl':
default:
return 'rounded-3xl'
}
}
const headerAlign = typeof payload.headerAlign === 'string'
? payload.headerAlign
: (layoutPreset === 'editorial' ? 'left' : 'center')
const showHeading = payload.showHeading !== false && headerAlign !== 'none'
const getHeaderAlignClass = (align: string) => {
switch (align) {
case 'left':
return 'text-left mr-auto items-start'
case 'right':
return 'text-right ml-auto items-end'
case 'center':
default:
return 'text-center mx-auto items-center'
}
}
const getAccentLineClass = (align: string) => {
switch (align) {
case 'left':
return 'mr-auto'
case 'right':
return 'ml-auto'
case 'center':
default:
return 'mx-auto'
}
}
const isLeftAlign = textAlign === 'left'
const isRightAlign = textAlign === 'right'
const alignContainerClass = isLeftAlign
? 'items-start text-left'
: isRightAlign
? 'items-end text-right'
: 'items-center text-center'
const padTop = typeof payload.paddingTop === 'string'
? payload.paddingTop
: (typeof payload.sectionPadding === 'string' ? payload.sectionPadding : 'normal')
const padBottom = typeof payload.paddingBottom === 'string'
? payload.paddingBottom
: (typeof payload.sectionPadding === 'string' ? payload.sectionPadding : 'normal')
const getPaddingTopClass = (pad: string) => {
switch (pad) {
case 'none':
return 'pt-0'
case 'compact':
return 'pt-6 md:pt-10'
case 'spacious':
return 'pt-20 md:pt-28'
case 'normal':
default:
return 'pt-10 md:pt-16'
}
}
const getPaddingBottomClass = (pad: string) => {
switch (pad) {
case 'none':
return 'pb-0'
case 'compact':
return 'pb-6 md:pb-10'
case 'spacious':
return 'pb-20 md:pb-28'
case 'normal':
default:
return 'pb-10 md:pb-16'
}
}
return (
<section
className={`overflow-hidden bg-surface-page px-6 ${getPaddingTopClass(padTop)} ${getPaddingBottomClass(padBottom)}`}
style={{ backgroundColor, color: textColor }}
>
<div className={`${getContainerWidthClass(containerWidth)} space-y-6 md:space-y-8`}>
{showHeading && (title || subtitle) && (
<div className={`max-w-2xl flex flex-col space-y-3 ${getHeaderAlignClass(headerAlign)}`}>
{title && (
<h2 className="text-3xl font-semibold leading-tight text-text-main md:text-5xl" style={{ color: textColor }}>
{renderAccentTitle(title)}
</h2>
)}
{subtitle && <p className="text-base leading-relaxed text-muted-text md:text-lg" style={{ color: textColor }}>{subtitle}</p>}
<div className={`h-px w-14 bg-brand-primary ${getAccentLineClass(headerAlign)}`} />
</div>
)}
<div className={`grid gap-6 md:gap-8 lg:gap-10 ${getColsClass(columns)}`}>
{tiles.map((tile, idx) => {
const tileTitle = localizedFromRecord(tile, 'title', resolvedLang)
const tileDesc = localizedFromRecord(tile, 'description', resolvedLang)
const buttonText = localizedFromRecord(tile, 'buttonText', resolvedLang)
const buttonLink = typeof tile.buttonLink === 'string' ? tile.buttonLink : ''
const imageUrl = typeof tile.imageUrl === 'string' ? tile.imageUrl : ''
const imageAspectRatio = typeof tile.imageAspectRatio === 'string' ? tile.imageAspectRatio : (sectionAspect || 'square')
const cardContent = (
<div className={`group flex flex-col ${alignContainerClass} space-y-4 w-full`}>
{imageUrl && (
<div className={`relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden ${getRoundedClass(imageRounded)} shadow-card bg-surface-card`}>
<Image
src={imageUrl}
alt={tileTitle || 'Campaign Card'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 ease-out group-hover:scale-105"
/>
</div>
)}
<div className={`space-y-2.5 w-full flex flex-col ${alignContainerClass}`}>
{tileTitle && (
<h3 className="font-serif text-lg md:text-xl lg:text-2xl font-normal leading-snug text-text-main group-hover:text-brand-primary transition-colors" style={{ color: textColor }}>
{tileTitle}
</h3>
)}
{tileDesc && (
<p className="text-sm leading-relaxed text-muted-text" style={{ color: textColor }}>
{tileDesc}
</p>
)}
{buttonText && ctaStyle !== 'none' && (
<div className={isLeftAlign ? 'pt-0.5' : 'pt-2'}>
{ctaStyle === 'arrow_link' ? (
<span className="inline-flex items-center gap-1.5 text-xs md:text-sm font-medium tracking-wide text-text-main group-hover:text-brand-primary transition-colors" style={{ color: textColor }}>
<span>{buttonText}</span>
<span className="transition-transform duration-200 group-hover:translate-x-1">→</span>
</span>
) : ctaStyle === 'solid_button' ? (
<span className="inline-flex rounded-xl bg-brand-primary text-surface-card px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:opacity-90 shadow-sm">
{buttonText}
</span>
) : (
<span className="inline-flex rounded-full border border-current px-7 py-2.5 text-xs font-bold uppercase tracking-[0.2em] transition hover:bg-brand-primary hover:text-surface-card">
{buttonText}
</span>
)}
</div>
)}
</div>
</div>
)
return buttonLink ? (
<Link key={idx} href={buttonLink} className="block w-full focus:outline-none">
{cardContent}
</Link>
) : (
<div key={idx} className="w-full">
{cardContent}
</div>
)
})}
</div>
</div>
</section>
)
}
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const bgClass = backgroundColor ? `bg-pop-${backgroundColor}` : 'bg-surface-page'
const textClass = textColor ? `text-pop-${textColor}` : 'text-text-main'
const isSplitScreen = payload.layout === 'split_screen'
const imagePosition = typeof payload.imagePosition === 'string' ? payload.imagePosition : 'left'
if (!slides.length) return null
return (
<section className={`overflow-hidden py-16 md:py-24 ${bgClass} ${textClass}`}>
<div className={isSplitScreen ? 'w-full space-y-12' : 'mx-auto max-w-7xl px-6 space-y-12'}>
{title && (
<div className={`space-y-4 text-center ${isSplitScreen ? 'px-4 md:px-8' : ''}`}>
<h2 className={`font-serif text-3xl font-light tracking-wide md:text-5xl ${textClass}`}>
{renderAccentTitle(title)}
</h2>
<div className="mx-auto h-px w-16 bg-brand-primary" />
</div>
)}
<div className="relative select-none pb-12">
<Swiper
modules={[Autoplay, Pagination, Navigation]}
spaceBetween={isSplitScreen ? 0 : 30}
slidesPerView={1}
breakpoints={isSplitScreen ? undefined : {
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 }
}}
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-brand-primary' }}
navigation={true}
autoplay={{ delay: 5000, disableOnInteraction: false }}
grabCursor={true}
className="w-full"
>
{slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
const description = localizedFromRecord(slide, 'description', resolvedLang)
const category = localizedFromRecord(slide, 'category', resolvedLang)
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
return (
<SwiperSlide key={index} className="h-auto flex flex-col">
{isSplitScreen ? (
<div className={`group grid grid-cols-1 bg-surface-card overflow-hidden transition-all duration-300 ${
imagePosition === 'left' ? 'md:grid-cols-[65%_35%]' : 'md:grid-cols-[35%_65%]'
}`}>
{imageUrl ? (
<div className={`relative aspect-[4/3] md:aspect-auto overflow-hidden h-64 md:h-full min-h-[300px] md:min-h-[500px] lg:min-h-[600px] ${
imagePosition === 'left' ? 'order-1 md:order-1' : 'order-1 md:order-2'
}`}>
<Image
src={imageUrl}
alt={slideTitle || 'Editorial slide'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
) : null}
<div className={`space-y-6 p-6 md:p-16 lg:p-24 flex flex-col justify-center ${
imagePosition === 'left' ? 'order-2 md:order-2' : 'order-2 md:order-1'
}`}>
<div className="space-y-3">
{category && <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-brand-primary">{category}</span>}
{slideTitle && <h3 className="font-serif text-xl font-light tracking-wide text-text-main">{renderAccentTitle(slideTitle)}</h3>}
{description && <p className="line-clamp-6 text-xs font-light leading-relaxed text-muted-text md:text-sm">{description}</p>}
</div>
{buttonText && buttonLink && (
<div className="pt-2">
<Link href={buttonLink} className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-brand-primary hover:text-text-main transition-colors group/link">
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div>
)}
</div>
</div>
) : (
<div className="ui-card overflow-hidden group flex flex-col h-full transition-all duration-300 hover:border-brand-primary/30">
{imageUrl ? (
<div className="relative aspect-[4/3] overflow-hidden">
<Image
src={imageUrl}
alt={slideTitle || 'Editorial slide'}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
) : null}
<div className="p-6 flex flex-col flex-1 justify-between space-y-6">
<div className="space-y-3">
{category && <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-brand-primary">{category}</span>}
{slideTitle && <h3 className="font-serif text-xl font-light tracking-wide text-text-main">{renderAccentTitle(slideTitle)}</h3>}
{description && <p className="line-clamp-3 text-xs font-light leading-relaxed text-muted-text md:text-sm">{description}</p>}
</div>
{buttonText && buttonLink && (
<div className="pt-2">
<Link href={buttonLink} className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-brand-primary hover:text-text-main transition-colors group/link">
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div>
)}
</div>
</div>
)}
</SwiperSlide>
)
})}
</Swiper>
</div>
</div>
</section>
)
}
function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Guide Article'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const category = localizedFromRecord(payload, 'category', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string'
? payload.imageUrl
: typeof payload.backgroundImageUrl === 'string'
? payload.backgroundImageUrl
: ''
const date = typeof payload.date === 'string'
? payload.date
: typeof payload.publishDate === 'string'
? payload.publishDate
: ''
const focalPoint = typeof payload.focalPoint === 'object' && payload.focalPoint ? (payload.focalPoint as { x: string, y: string }) : undefined
return (
<section className="ui-hero-shell min-h-[60vh] px-6 py-24 text-center md:min-h-[76vh]">
{imageUrl && (
<>
<Image src={imageUrl} alt={title} fill unoptimized className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }} />
</>
)}
<div className="relative z-10 mx-auto max-w-4xl space-y-6">
{category && <p className="text-xs font-bold uppercase tracking-[0.28em] text-brand-accent">{category}</p>}
<h1 className={`ui-hero-title ${getHeroTitleSizeClass(payload.titleSize)}`}>{title}</h1>
{subtitle && <p className="mx-auto max-w-2xl text-lg text-surface-card md:text-xl">{subtitle}</p>}
{(author || date) && (
<p className="text-xs font-bold uppercase tracking-[0.22em] text-surface-card">
{[author && `${t('dynamic.authorBy', { lng: resolvedLang })} ${author}`, date].filter(Boolean).join(' / ')}
</p>
)}
</div>
</section>
)
}
function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Notes'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const notes = [
[t('dynamic.notes.top', { lng: resolvedLang }), optionalText(payload.topNotes, resolvedLang) || 'The initial, lighter smells of the fragrance.'],
[t('dynamic.notes.heart', { lng: resolvedLang }), optionalText(payload.heartNotes ?? payload.middleNotes, resolvedLang) || 'The core of the fragrance, emerging just after the top notes.'],
[t('dynamic.notes.base', { lng: resolvedLang }), optionalText(payload.baseNotes, resolvedLang) || 'The deepest, richest scents that linger the longest.'],
]
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-6xl space-y-12 text-center">
<div className="mx-auto max-w-3xl space-y-4">
<h2 className="ui-section-title">{title}</h2>
{description && <p className="text-muted-text">{description}</p>}
</div>
<div className="grid gap-6 md:grid-cols-3">
{notes.map(([label, value]) => (
<article key={label} className="ui-section-card p-8 text-left">
<h3 className="mb-4 text-xs font-bold uppercase tracking-[0.24em] text-brand-primary">{label}</h3>
<p className="leading-relaxed text-muted-text">{value}</p>
</article>
))}
</div>
</div>
</section>
)
}
function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const quote = localizedFromRecord(payload, 'quote', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const role = localizedFromRecord(payload, 'role', resolvedLang)
if (!quote) return null
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto max-w-4xl text-center">
<p className="mb-8 text-7xl leading-none text-brand-primary/25">&quot;</p>
<blockquote className="text-3xl font-semibold italic leading-relaxed text-text-main md:text-5xl">
{quote}
</blockquote>
{(author || role) && (
<p className="mt-8 text-xs font-bold uppercase tracking-[0.24em] text-muted-text">
{[author, role].filter(Boolean).join(' / ')}
</p>
)}
</div>
</section>
)
}
function AccordionSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : []
if (!categories.length) return null
const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang)
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const iconColor = typeof payload.iconColor === 'string' ? payload.iconColor : undefined
return (
<section className={`${getSectionPaddingClasses(payload)}`} style={{ backgroundColor, color: textColor }}>
<div className="mx-auto max-w-4xl space-y-10">
{sectionTitle && <h2 className="ui-section-title">{sectionTitle}</h2>}
{categories.map((category, categoryIndex) => {
const questions = Array.isArray(category.questions) ? category.questions.map(toRecord) : []
const name = localizedFromRecord(category, 'name', resolvedLang)
const icon = typeof category.icon === 'string' ? category.icon : ''
return (
<div key={categoryIndex} className="space-y-5">
{name && (
<h2 className="flex items-center gap-3 text-2xl font-semibold">
{icon && <span style={{ color: iconColor }}>{icon}</span>}
{name}
</h2>
)}
<div className="space-y-4">
{questions.map((item, itemIndex) => {
const question = localizedFromRecord(item, 'q', resolvedLang)
const answer = localizedFromRecord(item, 'a', resolvedLang)
if (!question || !answer) return null
return (
<details key={itemIndex} className="group rounded-2xl bg-surface-card p-5 shadow-card">
<summary className="cursor-pointer list-none text-lg font-semibold marker:hidden">
{question}
</summary>
<p className="mt-4 whitespace-pre-wrap leading-relaxed text-muted-text">{answer}</p>
</details>
)
})}
</div>
</div>
)
})}
</div>
</section>
)
}
function ContactInfoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const address = localizedFromRecord(payload, 'address', resolvedLang)
const email = typeof payload.email === 'string' ? payload.email : ''
const line = typeof payload.line === 'string' ? payload.line : ''
const hours = localizedFromRecord(payload, 'hours', resolvedLang)
const items = [
{ key: 'address', label: t('dynamic.contactInfo.address', { lng: resolvedLang }), value: address, Icon: MapPin },
{ key: 'email', label: t('dynamic.contactInfo.email', { lng: resolvedLang }), value: email, Icon: Mail },
{ key: 'line', label: t('dynamic.contactInfo.line', { lng: resolvedLang }), value: line, Icon: MessageCircle },
{ key: 'hours', label: t('dynamic.contactInfo.hours', { lng: resolvedLang }), value: hours, Icon: Clock },
] as const
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-4">
{title && (
<div className="col-span-full mb-2">
<h2 className="ui-section-title">{title}</h2>
</div>
)}
{items.map(({ key, label, value, Icon }) => {
if (!value) return null
return (
<article key={key} className="ui-section-card-muted p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-card text-brand-primary">
<Icon className="h-6 w-6" />
</div>
<h3 className="mb-2 text-lg font-semibold text-text-main">{label}</h3>
{key === 'email' ? (
<a className="break-words text-muted-text transition hover:text-brand-primary" href={`mailto:${value}`}>
{value}
</a>
) : (
<p className="whitespace-pre-wrap break-words text-muted-text">{value}</p>
)}
</article>
)
})}
</div>
</section>
)
}
function ContactFormSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const { t } = useTranslation()
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const [formData, setFormData] = useState({ name: '', email: '', message: '' })
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
setIsSuccess(false)
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3051/api';
const res = await fetch(`${apiUrl}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) {
throw new Error(data.error || 'Failed to send message');
}
setIsSuccess(true)
setFormData({ name: '', email: '', message: '' })
setTimeout(() => setIsSuccess(false), 3000)
} catch (err: any) {
console.error(err);
setError(err.message || 'An unexpected error occurred. Please try again.');
} finally {
setIsSubmitting(false);
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
return (
<section className="ui-section-band bg-surface-page px-4 py-20 md:px-8">
<div className="mx-auto max-w-3xl">
<div className="mb-12 text-center">
<h2 className="mb-4 text-3xl font-semibold text-text-main md:text-4xl">
{title || t('contactForm.title', { lng: resolvedLang })}
</h2>
<p className="mx-auto max-w-xl text-lg text-muted-text">
{subtitle || t('contactForm.subtitle', { lng: resolvedLang })}
</p>
</div>
<div className="rounded-3xl border border-line-primary/20 bg-surface-card p-8 shadow-card md:p-12">
{error && (
<div className="mb-6 rounded-lg bg-rose-50 p-4 text-rose-500 text-sm border border-rose-200">
{error}
</div>
)}
{isSuccess ? (
<div className="py-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-brand-success/20 text-brand-success">
<Send size={32} />
</div>
<h3 className="mb-2 text-2xl font-semibold text-text-main">{t('contactForm.successTitle', { lng: resolvedLang })}</h3>
<p className="text-muted-text">{t('contactForm.successBody', { lng: resolvedLang })}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="name" className="block text-sm font-medium text-text-main">
{t('contactForm.name', { lng: resolvedLang })}
</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
placeholder={t('contactForm.namePlaceholder', { lng: resolvedLang })}
className="ui-form-shell"
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-text-main">
{t('contactForm.email', { lng: resolvedLang })}
</label>
<input
type="email"
id="email"
name="email"
required
value={formData.email}
onChange={handleChange}
className="ui-form-shell"
placeholder="example@email.com"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="message" className="block text-sm font-medium text-text-main">
{t('contactForm.message', { lng: resolvedLang })}
</label>
<textarea
id="message"
name="message"
required
rows={4}
value={formData.message}
onChange={handleChange}
placeholder={t('contactForm.messagePlaceholder', { lng: resolvedLang })}
className="ui-form-shell resize-none"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-6 py-4 font-semibold text-white transition-colors hover:bg-brand-primary-hover disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? (
<span className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
<Send size={18} />
{t('contactForm.submit', { lng: resolvedLang })}
</>
)}
</button>
</form>
)}
</div>
</div>
</section>
)
}
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
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 = isHeroFirst ? 'true' : 'false'
}
return () => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = 'false'
}
}
}, [sections, isHeroFirst])
return (
<div className="flex w-full flex-col">
{sections.map((section) => {
if (!section.isActive) return null
const payload = toRecord(section.payload)
const type = section.type.toUpperCase()
const isFirstContent = section.id === firstActiveSection?.id
const needsTopSpacing = isFirstContent && !isHeroFirst
const rendered = (() => {
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
case 'HERO':
return (
<ScrollReveal key={section.id}>
<HeroSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_V2':
return (
<ScrollReveal key={section.id}>
<HeroV2Section payload={payload} lang={lang} />
</ScrollReveal>
)
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 'DIVIDER':
return <PageDivider key={section.id} payload={payload as DividerSectionPayload} />
case 'HERO_MINIMAL':
return (
<ScrollReveal key={section.id}>
<HeroMinimalSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_SPLIT_MINIMAL':
return (
<ScrollReveal key={section.id}>
<HeroSplitMinimalSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_VIDEO_MINIMAL':
return <HeroVideoMinimalSection key={section.id} payload={payload} lang={lang} />
case 'BANNER_MINIMAL':
return (
<ScrollReveal key={section.id}>
<BannerMinimalSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ANNOUNCEMENT_BAR_MINIMAL':
return <AnnouncementBarMinimal key={section.id} payload={payload} lang={lang} />
case 'USP_MINIMAL':
return (
<ScrollReveal key={section.id}>
<UspMinimalSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'USP_BAR':
return (
<ScrollReveal key={section.id}>
<UspBarSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'CATEGORIES':
return (
<ScrollReveal key={section.id}>
<CategoriesSection payload={payload} categories={categories} lang={lang} />
</ScrollReveal>
)
case 'PRODUCTS':
return (
<ScrollReveal key={section.id}>
<ProductsSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'PRODUCT_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<ProductsSection payload={{ layout: 'carousel', ...payload }} lang={lang} />
</ScrollReveal>
)
case 'BANNER':
return (
<ScrollReveal key={section.id}>
<BannerSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'TEXT':
return (
<ScrollReveal key={section.id}>
<TextSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'NEWSLETTER':
return (
<ScrollReveal key={section.id}>
<NewsletterSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'EDITORIAL_TILE':
return (
<ScrollReveal key={section.id}>
<EditorialTileSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'FEATURED_TILES':
return (
<ScrollReveal key={section.id}>
<FeaturedTilesSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'EDITORIAL_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<EditorialCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_ARTICLE':
return (
<ScrollReveal key={section.id}>
<HeroArticleSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'RICH_TEXT':
return (
<ScrollReveal key={section.id}>
<TextSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ARTICLE_HEADER':
return <ArticleHeaderSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_MEDIA':
return (
<ScrollReveal key={section.id}>
<ArticleMediaSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ARTICLE_GALLERY':
return <ArticleGallerySection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_FACTS':
return <ArticleFactsSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_VIDEO':
return <ArticleVideoSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_REFERENCES':
return <ArticleReferencesSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_TABLE':
return <ArticleTableSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_DOWNLOAD':
return <ArticleDownloadSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_CTA':
return <ArticleCtaSection key={section.id} payload={payload} lang={lang} />
case 'SCENT_NOTES_BREAKDOWN':
return (
<ScrollReveal key={section.id}>
<ScentNotesBreakdownSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'QUOTE_CALLOUT':
return (
<ScrollReveal key={section.id}>
<QuoteCalloutSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ACCORDION':
return (
<ScrollReveal key={section.id}>
<AccordionSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'CONTACT_INFO':
return (
<ScrollReveal key={section.id}>
<ContactInfoSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'CONTACT_FORM':
return (
<ScrollReveal key={section.id}>
<ContactFormSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'CUSTOM': {
const componentId = typeof payload.componentId === 'string' ? payload.componentId : ''
const props = toRecord(payload.props)
const CustomComponent = customRegistry[componentId]
if (!CustomComponent) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unknown custom component: ${componentId}`)
}
return null
}
return (
<ScrollReveal key={section.id}>
<CustomComponent props={props} lang={lang} />
</ScrollReveal>
)
}
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
}
return null
}
})()
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-height, 72px)' }} className="w-full">
{rendered}
</div>
)
}
return rendered
})}
</div>
)
}