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

No files matched your search

+161 -30
View File
@@ -211,12 +211,27 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
}, [pathname])
const isHome = getPathWithoutLocale(pathname) === '/'
const isTransparent = (isHome || isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const isTransparent = (isHeroFirst || Boolean(isTransparentPage)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
const pathWithoutLocale = getPathWithoutLocale(pathname)
const isCommercePath = ['/shop', '/products', '/category', '/cart', '/checkout', '/wishlist', '/profile', '/order', '/track']
.some((route) => pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`))
const hideDesktopHeader =
(!isCommercePath && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) ||
const headerBehavior =
(settings?.themeTokens as any)?.headerBehavior ||
settings?.headerBehavior ||
'hide_on_scroll'
const mobileHeaderBehavior =
(settings?.themeTokens as any)?.mobileHeaderBehavior ||
settings?.mobileHeaderBehavior ||
headerBehavior
const mobileHeaderStyle =
(settings?.themeTokens as any)?.mobileHeaderStyle ||
settings?.mobileHeaderStyle ||
'logo_center'
const isDesktopView = isClientReady && canRenderDesktopMenu
const activeHeaderBehavior = isDesktopView ? headerBehavior : mobileHeaderBehavior
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
const isCheckout = pathWithoutLocale === '/checkout' || pathWithoutLocale.startsWith('/checkout/')
const hideHeader =
(!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen) ||
(productStickyVisible && !mobileOpen && !searchOpen && !langSheetOpen)
useEffect(() => {
@@ -233,6 +248,138 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const isTwoTier = settings?.headerStyle === 'two_tier'
const renderMobileActionButtons = (includeSearch = true) => (
<div className="flex items-center gap-1.5 shrink-0">
{includeSearch && (
<button
onClick={() => {
setSearchOpen(true)
setMobileOpen(false)
}}
className={iconButtonClass}
aria-label="Search"
>
<Search className="h-4 w-4 transition-colors" />
</button>
)}
<Link
href="/wishlist"
onClick={() => setMobileOpen(false)}
className={`relative ${iconButtonClass}`}
aria-label="Wishlist"
>
<Heart className="h-4 w-4 transition-colors" />
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-brand-primary text-[9px] font-bold text-surface-card">
{wishlistCount}
</span>
)}
</Link>
<button
onClick={() => {
toggleCart(true)
setMobileOpen(false)
}}
className={`relative ${iconButtonClass}`}
aria-label="Cart"
>
<ShoppingBag className="h-4 w-4 transition-colors" />
{cartCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-brand-primary text-[9px] font-bold text-surface-card">
{cartCount}
</span>
)}
</button>
</div>
)
const renderMobileHamburger = () => (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)
const renderMobileNavbar = () => {
if (mobileHeaderStyle === 'logo_left') {
return (
<div className="flex w-full items-center justify-between gap-3 md:hidden">
<div className="flex shrink-0 items-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={150} height={38} unoptimized className="h-8 w-auto object-contain" />
) : (
<span className="text-xl font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(true)}
{renderMobileHamburger()}
</div>
</div>
)
}
if (mobileHeaderStyle === 'search_bar_below') {
return (
<div className="flex w-full flex-col gap-2 md:hidden">
<div className="flex w-full items-center justify-between gap-3">
<div className="flex shrink-0 items-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={140} height={35} unoptimized className="h-7 w-auto object-contain" />
) : (
<span className="text-lg font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{renderMobileActionButtons(false)}
{renderMobileHamburger()}
</div>
</div>
<form onSubmit={handleSearch} className="relative w-full">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('nav.searchPlaceholder', 'ค้นหาสินค้า กลิ่นหอม หรือหมวดหมู่...')}
className="w-full rounded-full border border-black/10 bg-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-text-main placeholder:text-muted-text/60 focus:outline-none focus:ring-1 focus:ring-brand-primary dark:border-white/10 dark:bg-white/[0.06]"
/>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-text" />
</form>
</div>
)
}
// Default: 'logo_center'
return (
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center">
{renderMobileHamburger()}
</div>
<div className="flex items-center justify-center text-center">
<Link href="/" onClick={() => setMobileOpen(false)} className="ui-brand-wordmark text-center">
{settings?.logoUrl ? (
<Image src={settings.logoUrl} alt={brandName} width={150} height={38} unoptimized className="h-8 w-auto object-contain" />
) : (
<span className="text-xl font-bold uppercase tracking-[0.2em] text-text-main">{brandName}</span>
)}
</Link>
</div>
<div className="flex items-center justify-end">
{renderMobileActionButtons(true)}
</div>
</div>
)
}
return (
<>
<nav
@@ -241,7 +388,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
className={`global-navbar fixed top-0 z-50 w-full border-b transition-all duration-300 ${
isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'
} ${
hideDesktopHeader ? 'md:pointer-events-none md:-translate-y-full' : 'translate-y-0'
hideHeader ? 'pointer-events-none -translate-y-full' : 'translate-y-0'
} ${
scrolled
? 'border-black/5 bg-surface-page/95 shadow-lg shadow-black/5 backdrop-blur-xl'
@@ -250,23 +397,17 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
: 'border-black/5 bg-surface-page/80 backdrop-blur-xl md:bg-surface-nav-rest md:backdrop-blur-none'
}`}
>
{/* Mobile Navigation Bar (< md) */}
{renderMobileNavbar()}
{/* Desktop Navigation Bar (>= md) */}
{isTwoTier ? (
<div className="flex w-full flex-col">
{/* Top Tier: Logo Centered + Icons Right (Desktop) & Mobile Top Row */}
<div className="hidden md:flex w-full flex-col">
{/* Top Tier: Logo Centered + Icons Right (Desktop) */}
<div className={`flex w-full items-center justify-between transition-all duration-300 md:grid md:grid-cols-3 ${
scrolled ? 'max-h-0 opacity-0 overflow-hidden py-0' : 'max-h-24 py-1.5 opacity-100'
}`}>
<div className="flex items-center">
{isClientReady && !canRenderDesktopMenu && (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)}
</div>
<div className="flex items-center" />
<div className="flex items-center justify-center text-center">
<Link
@@ -422,7 +563,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
</div>
</div>
) : (
<div className="flex w-full items-center justify-between gap-4">
<div className="hidden md:flex w-full items-center justify-between gap-4">
<div className="flex shrink-0 items-center">
<Link
href="/"
@@ -542,16 +683,6 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
</div>
</>
)}
{isClientReady && !canRenderDesktopMenu && (
<button
onClick={() => setMobileOpen((prev) => !prev)}
className={iconButtonClass}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
</button>
)}
</div>
</div>
)}
@@ -3,7 +3,7 @@
import Image from 'next/image'
import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard'
@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'
import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -155,6 +156,129 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
)
}
function HeroCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const slides = Array.isArray(payload.slides) ? payload.slides : []
const autoplay = typeof payload.autoplay === 'boolean' ? payload.autoplay : true
const interval = typeof payload.interval === 'number' ? payload.interval : 4500
const showArrows = typeof payload.showArrows === 'boolean' ? payload.showArrows : true
const showDots = typeof payload.showDots === 'boolean' ? payload.showDots : true
const aspectRatio = typeof payload.aspectRatio === 'string' ? payload.aspectRatio : '21/7'
const mobileAspectRatio = typeof payload.mobileAspectRatio === 'string' ? payload.mobileAspectRatio : '16/9'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
if (!slides.length) return null
const getAspectClass = () => {
switch (aspectRatio) {
case '21/6': return 'md:aspect-[21/6]'
case '21/7': return 'md:aspect-[21/7]'
case '21/9': return 'md:aspect-[21/9]'
case '16/5': return 'md:aspect-[16/5]'
case '16/6': return 'md:aspect-[16/6]'
case '4/1': return 'md:aspect-[4/1]'
case '5/1': return 'md:aspect-[5/1]'
default: return 'md:aspect-[21/7]'
}
}
const getMobileAspectClass = () => {
switch (mobileAspectRatio) {
case '16/9': return 'aspect-[16/9]'
case '4/3': return 'aspect-[4/3]'
case '1/1': return 'aspect-[1/1]'
case '21/9': return 'aspect-[21/9]'
default: return 'aspect-[16/9]'
}
}
return (
<section className="relative w-full overflow-hidden bg-surface-page" style={{ backgroundColor }}>
<Swiper
modules={[Autoplay, Pagination, Navigation]}
slidesPerView={1}
loop={slides.length > 1}
autoplay={autoplay ? { delay: interval, disableOnInteraction: false } : false}
pagination={showDots ? { clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-brand-primary' } : false}
navigation={showArrows}
grabCursor={true}
className="w-full select-none [&_.swiper-button-next]:text-white [&_.swiper-button-prev]:text-white [&_.swiper-button-next]:after:text-sm md:[&_.swiper-button-next]:after:text-base [&_.swiper-button-prev]:after:text-sm md:[&_.swiper-button-prev]:after:text-base [&_.swiper-button-next]:w-8 md:[&_.swiper-button-next]:w-10 [&_.swiper-button-next]:h-8 md:[&_.swiper-button-next]:h-10 [&_.swiper-button-prev]:w-8 md:[&_.swiper-button-prev]:w-10 [&_.swiper-button-prev]:h-8 md:[&_.swiper-button-prev]:h-10 [&_.swiper-button-next]:bg-black/35 [&_.swiper-button-prev]:bg-black/35 [&_.swiper-button-next]:backdrop-blur-sm [&_.swiper-button-prev]:backdrop-blur-sm [&_.swiper-button-next]:rounded-full [&_.swiper-button-prev]:rounded-full [&_.swiper-button-next]:hover:bg-black/60 [&_.swiper-button-prev]:hover:bg-black/60 [&_.swiper-pagination-bullet]:bg-white/60 [&_.swiper-pagination-bullet-active]:!bg-brand-primary [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4"
>
{slides.map((slide, index) => {
const title = localizedFromRecord(slide, 'title', resolvedLang)
const subtitle = localizedFromRecord(slide, 'subtitle', resolvedLang)
const alt = localizedFromRecord(slide, 'alt', resolvedLang) || title || `Slide ${index + 1}`
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
const mobileImageUrl = typeof slide.mobileImageUrl === 'string' && slide.mobileImageUrl ? slide.mobileImageUrl : imageUrl
const linkUrl = typeof slide.linkUrl === 'string' ? slide.linkUrl : ''
const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? (slide.focalPoint as { x: string, y: string }) : undefined
if (!imageUrl) return null
const slideContent = (
<div className={`relative w-full ${getMobileAspectClass()} ${getAspectClass()} overflow-hidden`}>
{/* Desktop Image */}
<div className="hidden md:block absolute inset-0 w-full h-full">
<Image
src={imageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Mobile Image */}
<div className="block md:hidden absolute inset-0 w-full h-full">
<Image
src={mobileImageUrl}
alt={alt}
fill
unoptimized
priority={index === 0}
className="object-cover w-full h-full"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
/>
</div>
{/* Optional Text Overlay */}
{(title || subtitle || buttonText) && (
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/70 via-black/20 to-transparent flex flex-col justify-end p-6 md:p-10">
<div className="max-w-xl space-y-1.5 md:space-y-2">
{subtitle && <p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-brand-accent drop-shadow-md">{subtitle}</p>}
{title && <h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">{renderAccentTitle(title)}</h3>}
{buttonText && (
<span className="inline-block mt-2 px-4 py-1.5 bg-brand-primary text-white text-xs font-bold rounded-full shadow-lg hover:bg-brand-primary-hover transition-all">
{buttonText}
</span>
)}
</div>
</div>
)}
</div>
)
return (
<SwiperSlide key={index}>
{linkUrl ? (
<Link href={linkUrl} className="block w-full h-full group cursor-pointer">
{slideContent}
</Link>
) : (
slideContent
)}
</SwiperSlide>
)
})}
</Swiper>
</section>
)
}
function HeroVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1888,32 +2012,36 @@ function ContactFormSection({ payload, lang }: { payload: Record<string, unknown
)
}
import { useEffect } from 'react'
import ScrollReveal from '../ScrollReveal'
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
const i18nLang = useLang()
// Use i18n.language reactively — switches immediately when user changes language
const lang = langProp ?? i18nLang
useEffect(() => {
const HERO_TYPES = new Set([
'HERO',
'HERO_V2',
'HERO_VIDEO',
'HERO_ARTICLE',
'HERO_MINIMAL',
'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL',
])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR')
if (!firstActiveSection && sections.length > 0 && sections.every(s => !s.isActive || s.type.toUpperCase() === 'ANNOUNCEMENT_BAR')) {
return
}
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()))
const HERO_TYPES = useMemo(() => new Set([
'HERO',
'HERO_V2',
'HERO_VIDEO',
'HERO_ARTICLE',
'HERO_MINIMAL',
'HERO_SPLIT_MINIMAL',
'HERO_VIDEO_MINIMAL',
'HERO_CAROUSEL',
]), [])
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR_MINIMAL')
const firstPayload = toRecord(firstActiveSection?.payload)
const defaultSpacing = firstActiveSection?.type.toUpperCase() === 'HERO_CAROUSEL' ? 'below_header' : 'under_header'
const firstHeaderSpacing = typeof firstPayload.headerSpacing === 'string' ? firstPayload.headerSpacing : defaultSpacing
const isHeroFirst = Boolean(
firstActiveSection &&
HERO_TYPES.has(firstActiveSection.type.toUpperCase()) &&
firstHeaderSpacing === 'under_header'
)
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false'
document.documentElement.dataset.heroFirst = isHeroFirst ? 'true' : 'false'
}
return () => {
@@ -1921,7 +2049,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
document.documentElement.dataset.heroFirst = 'false'
}
}
}, [sections])
}, [sections, isHeroFirst])
return (
<div className="flex w-full flex-col">
@@ -1930,10 +2058,13 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
const payload = toRecord(section.payload)
const type = section.type.toUpperCase()
const isFirstContent = section.id === firstActiveSection?.id
const needsTopSpacing = isFirstContent && !isHeroFirst
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
const rendered = (() => {
switch (type) {
case 'ANNOUNCEMENT_BAR':
return <AnnouncementBar key={section.id} payload={payload} lang={lang} />
case 'HERO':
return (
<ScrollReveal key={section.id}>
@@ -1948,6 +2079,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
)
case 'HERO_VIDEO':
return <HeroVideoSection key={section.id} payload={payload} lang={lang} />
case 'HERO_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<HeroCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'HERO_MINIMAL':
return (
<ScrollReveal key={section.id}>
@@ -2124,7 +2261,18 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
}
return null
}
})}
})()
if (needsTopSpacing && rendered) {
return (
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
{rendered}
</div>
)
}
return rendered
})}
</div>
)
}