2983 lines
131 KiB
TypeScript
2983 lines
131 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 CinematicStoryScroll from '../CinematicStoryScroll'
|
|
import AppleWatchShowcase from '../AppleWatchShowcase'
|
|
import Interactive360Showcase from '../Interactive360Showcase'
|
|
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">→</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">→</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">"</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',
|
|
'CINEMATIC_STORY_SCROLL',
|
|
'STORY_SCROLL',
|
|
'APPLE_SHOWCASE',
|
|
'APPLE_KEYNOTE_SHOWCASE',
|
|
'APPLE_WATCH_TOUR',
|
|
]), [])
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
case 'INTERACTIVE_360_SHOWCASE':
|
|
case 'TURNTABLE_3D_SHOWCASE':
|
|
case 'TURNTABLE_360': {
|
|
return (
|
|
<div key={section.id} className="w-full">
|
|
<Interactive360Showcase
|
|
spriteSheetUrl={(payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'}
|
|
detailImageUrl={(payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'}
|
|
columns={Number(payload.columns) || 4}
|
|
rows={Number(payload.rows) || 4}
|
|
eyebrow={payload.eyebrow as string | undefined}
|
|
title={payload.title as string | undefined}
|
|
subtitle={payload.subtitle as string | undefined}
|
|
detailKicker={payload.detailKicker as string | undefined}
|
|
detailTitle={payload.detailTitle as string | undefined}
|
|
detailCaption={payload.detailCaption as string | undefined}
|
|
productName={payload.productName as string | undefined}
|
|
productBadge={payload.productBadge as string | undefined}
|
|
specs={Array.isArray(payload.specs) ? (payload.specs as any) : undefined}
|
|
endCtaTitle={payload.endCtaTitle as string | undefined}
|
|
endCtaButtonText={payload.endCtaButtonText as string | undefined}
|
|
endCtaLink={payload.endCtaLink as string | undefined}
|
|
theme={(payload.theme as 'dark' | 'light') || 'dark'}
|
|
accentColor={(payload.accentColor as string) || '#d5ff63'}
|
|
lang={lang}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
case 'CINEMATIC_STORY_SCROLL':
|
|
case 'STORY_SCROLL': {
|
|
const rawTheme = payload.storyTheme as string | undefined
|
|
const storyTheme = rawTheme === 'dark' ? 'dark' : 'light'
|
|
const storyHeroImage = (payload.storyHeroImage as string) || (payload.heroImage as string) || undefined
|
|
const storyScenes = Array.isArray(payload.storyScenes) ? (payload.storyScenes as any[]) : undefined
|
|
const endCtaTitle = (payload.endCtaTitle as string) || undefined
|
|
const endCtaButtonText = (payload.endCtaButtonText as string) || undefined
|
|
const endCtaLink = (payload.endCtaLink as string) || undefined
|
|
|
|
const targetProductId = Number(payload.productId) || 0
|
|
const foundProduct = targetProductId
|
|
? (categories.flatMap(c => (c as any).products || []) as Product[]).find(p => p.id === targetProductId)
|
|
: undefined
|
|
|
|
const mockProduct: Product = foundProduct || {
|
|
id: targetProductId || 1,
|
|
name: { en: 'Product Story', th: 'การนำเสนอผลิตภัณฑ์' },
|
|
description: { en: 'High Performance Story', th: 'การขับขี่สมรรถนะสูง' },
|
|
details: null,
|
|
price: 0,
|
|
brandId: 1,
|
|
categoryId: 1,
|
|
images: storyHeroImage ? [{ id: 1, url: storyHeroImage, order: 0 }] : [],
|
|
variants: [],
|
|
options: [
|
|
{
|
|
id: 1,
|
|
name: { en: 'Model', th: 'รุ่น' },
|
|
values: ['NVX155', 'LC135', 'Vario125', 'Vario150', 'PCX150', 'Beat110', 'Wave110-I', 'R15'] as any
|
|
}
|
|
] as any,
|
|
category: { id: 1, name: 'Braking' } as any,
|
|
brand: { id: 1, name: 'Brand Story' } as any,
|
|
}
|
|
|
|
return (
|
|
<div key={section.id} className="w-full">
|
|
<CinematicStoryScroll
|
|
product={mockProduct}
|
|
heroImage={storyHeroImage}
|
|
scenes={storyScenes}
|
|
lang={lang}
|
|
endCtaTitle={endCtaTitle}
|
|
endCtaButtonText={endCtaButtonText}
|
|
endCtaLink={endCtaLink}
|
|
storyTheme={storyTheme}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
case 'APPLE_SHOWCASE':
|
|
case 'APPLE_KEYNOTE_SHOWCASE':
|
|
case 'APPLE_WATCH_TOUR': {
|
|
const storyHeroImage = (payload.heroImage as string) || (payload.storyHeroImage as string) || undefined
|
|
const targetProductId = Number(payload.productId) || 0
|
|
const foundProduct = targetProductId
|
|
? (categories.flatMap(c => (c as any).products || []) as Product[]).find(p => p.id === targetProductId)
|
|
: undefined
|
|
|
|
const mockProduct: Product = foundProduct || {
|
|
id: targetProductId || 1,
|
|
name: { en: 'Keynote Showcase', th: 'การนำเสนอผลิตภัณฑ์' },
|
|
description: { en: 'Next-Gen Performance', th: 'นวัตกรรมมาตรฐานระดับโลก' },
|
|
details: null,
|
|
price: 0,
|
|
brandId: 1,
|
|
categoryId: 1,
|
|
images: storyHeroImage ? [{ id: 1, url: storyHeroImage, order: 0 }] : [],
|
|
variants: [],
|
|
category: { id: 1, name: 'Technology' } as any,
|
|
brand: { id: 1, name: 'Keynote' } as any,
|
|
}
|
|
|
|
return (
|
|
<div key={section.id} className="w-full">
|
|
<AppleWatchShowcase
|
|
product={mockProduct}
|
|
heroImage={storyHeroImage}
|
|
lang={lang}
|
|
endCtaTitle={payload.endCtaTitle as string | undefined}
|
|
endCtaButtonText={payload.endCtaButtonText as string | undefined}
|
|
endCtaLink={payload.endCtaLink as string | undefined}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|