x
This commit is contained in:
1 parent
3ac0cb4f53
commit
5e724fae20
17 files changed
+2404
-278
No files matched your search
@@ -0,0 +1,157 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination, Navigation } from 'swiper/modules';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { HeroCarouselSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import 'swiper/css/navigation';
|
||||
|
||||
interface DynamicHeroCarouselProps {
|
||||
payload: HeroCarouselSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicHeroCarousel({ payload }: DynamicHeroCarouselProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const slides = (payload.slides || []).map((slide) => ({
|
||||
...slide,
|
||||
title: getLocalizedField(slide.title, lang),
|
||||
subtitle: getLocalizedField(slide.subtitle, lang),
|
||||
buttonText: getLocalizedField(slide.buttonText, lang),
|
||||
alt: getLocalizedField(slide.alt, lang),
|
||||
}));
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
const autoplay = payload.autoplay !== false;
|
||||
const interval = payload.interval || 4500;
|
||||
const showArrows = payload.showArrows !== false;
|
||||
const showDots = payload.showDots !== false;
|
||||
const aspectRatio = payload.aspectRatio || '21/7';
|
||||
const mobileAspectRatio = payload.mobileAspectRatio || '16/9';
|
||||
|
||||
const renderTitle = (text: string) => {
|
||||
if (!text) return null;
|
||||
return text.split('[').map((part, i) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']');
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-pop-pink font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
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-pop-bg">
|
||||
<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-pop-pink' } : 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-pop-pink [&_.swiper-pagination]:!bottom-2.5 md:[&_.swiper-pagination]:!bottom-4"
|
||||
>
|
||||
{slides.map((slide, index) => {
|
||||
const imageUrl = slide.imageUrl || '';
|
||||
const mobileImageUrl = slide.mobileImageUrl || imageUrl;
|
||||
const linkUrl = slide.linkUrl || '';
|
||||
const altText = slide.alt || slide.title || `Slide ${index + 1}`;
|
||||
const focalPoint = typeof slide.focalPoint === 'object' && slide.focalPoint ? slide.focalPoint : undefined;
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
const slideContent = (
|
||||
<div className={`relative w-full ${getMobileAspectClass()} ${getAspectClass()} overflow-hidden`}>
|
||||
{/* Desktop Image */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
className="hidden md:block absolute inset-0 w-full h-full object-cover"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
|
||||
{/* Mobile Image */}
|
||||
<img
|
||||
src={mobileImageUrl}
|
||||
alt={altText}
|
||||
className="block md:hidden absolute inset-0 w-full h-full object-cover"
|
||||
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
|
||||
/>
|
||||
|
||||
{/* Text Overlay */}
|
||||
{(slide.title || slide.subtitle || slide.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">
|
||||
{slide.subtitle && (
|
||||
<p className="text-[10px] md:text-xs uppercase font-bold tracking-widest text-pop-yellow drop-shadow-md">
|
||||
{slide.subtitle}
|
||||
</p>
|
||||
)}
|
||||
{slide.title && (
|
||||
<h3 className="text-lg md:text-3xl font-bold text-white drop-shadow-md leading-tight">
|
||||
{renderTitle(slide.title)}
|
||||
</h3>
|
||||
)}
|
||||
{slide.buttonText && (
|
||||
<span className="inline-block mt-2 px-4 py-1.5 bg-pop-pink text-white text-xs font-bold rounded-full shadow-lg hover:bg-pop-red transition-all">
|
||||
{slide.buttonText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SwiperSlide key={index}>
|
||||
{linkUrl ? (
|
||||
<Link to={linkUrl} className="block w-full h-full group cursor-pointer">
|
||||
{slideContent}
|
||||
</Link>
|
||||
) : (
|
||||
slideContent
|
||||
)}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { PageSection, Category, AccordionSectionPayload, ContactInfoSection
|
||||
import DynamicHero from './DynamicHero';
|
||||
import DynamicHeroV2 from './DynamicHeroV2';
|
||||
import DynamicHeroVideo from './DynamicHeroVideo';
|
||||
import DynamicHeroCarousel from './DynamicHeroCarousel';
|
||||
import DynamicUspBar from './DynamicUspBar';
|
||||
import DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
@@ -36,24 +37,30 @@ export default function DynamicPageRenderer({
|
||||
categories,
|
||||
categoriesLoading
|
||||
}: DynamicPageRendererProps) {
|
||||
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 = new Set([
|
||||
'HERO',
|
||||
'HERO_V2',
|
||||
'HERO_VIDEO',
|
||||
'HERO_CAROUSEL',
|
||||
'HERO_ARTICLE',
|
||||
'HERO_MINIMAL',
|
||||
'HERO_SPLIT_MINIMAL',
|
||||
'HERO_VIDEO_MINIMAL',
|
||||
]);
|
||||
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||
const firstPayload = (firstActiveSection?.payload || {}) as Record<string, unknown>;
|
||||
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 () => {
|
||||
@@ -61,23 +68,29 @@ export default function DynamicPageRenderer({
|
||||
document.documentElement.dataset.heroFirst = 'false';
|
||||
}
|
||||
};
|
||||
}, [sections]);
|
||||
}, [sections, isHeroFirst]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{sections.map((section) => {
|
||||
if (!section.isActive) return null;
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
const isFirstContent = section.id === firstActiveSection?.id;
|
||||
const needsTopSpacing = isFirstContent && !isHeroFirst;
|
||||
|
||||
const rendered = (() => {
|
||||
switch (section.type.toUpperCase()) {
|
||||
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
case 'HERO':
|
||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||
case 'HERO_V2':
|
||||
return <DynamicHeroV2 key={section.id} payload={section.payload} />;
|
||||
case 'HERO_VIDEO':
|
||||
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
|
||||
case 'HERO_CAROUSEL':
|
||||
return <DynamicHeroCarousel key={section.id} payload={section.payload} />;
|
||||
case 'USP_BAR':
|
||||
return <DynamicUspBar key={section.id} payload={section.payload} />;
|
||||
|
||||
@@ -148,7 +161,18 @@ export default function DynamicPageRenderer({
|
||||
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
})();
|
||||
|
||||
if (needsTopSpacing && rendered) {
|
||||
return (
|
||||
<div key={section.id} style={{ paddingTop: 'var(--navbar-bottom, 72px)' }} className="w-full">
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+324
-129
@@ -39,6 +39,8 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
|
||||
const { megaMenu, fetchMegaMenu } = useMenuStore()
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const previousScrollY = useRef(0)
|
||||
const [scrollingDown, setScrollingDown] = useState(false)
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'th', label: 'ไทย', short: 'TH' },
|
||||
@@ -66,8 +68,19 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
checkAnnouncement()
|
||||
const timer = setTimeout(checkAnnouncement, 300)
|
||||
|
||||
previousScrollY.current = window.scrollY
|
||||
const onScroll = () => {
|
||||
setScrolled(window.scrollY > 20)
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - previousScrollY.current
|
||||
setScrolled(currentScrollY > 20)
|
||||
if (currentScrollY <= 20) {
|
||||
setScrollingDown(false)
|
||||
} else if (delta > 4) {
|
||||
setScrollingDown(true)
|
||||
} else if (delta < -4) {
|
||||
setScrollingDown(false)
|
||||
}
|
||||
previousScrollY.current = currentScrollY
|
||||
checkAnnouncement()
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
@@ -163,155 +176,337 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
return () => observer.disconnect();
|
||||
}, [location.pathname]);
|
||||
|
||||
const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !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 activeHeaderBehavior = isDesktop ? headerBehavior : mobileHeaderBehavior
|
||||
const isHideOnScroll = activeHeaderBehavior === 'hide_on_scroll'
|
||||
const isCheckout = location.pathname.startsWith('/checkout')
|
||||
const hideHeader =
|
||||
!isCheckout && isHideOnScroll && scrollingDown && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
|
||||
const shouldBeTransparent = (isHeroFirst || Boolean(isTransparent && isHeroFirst)) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
|
||||
const isTwoTier = settings?.headerStyle === 'two_tier'
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Wishlist */}
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Cart */}
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Language switcher (desktop) */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="relative group flex items-center">
|
||||
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||
</button>
|
||||
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||
{LANGUAGES.map(lang => {
|
||||
const isActive = i18n.language.startsWith(lang.code)
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auth / Profile (desktop) */}
|
||||
{isClientReady && isDesktop && (
|
||||
customer ? (
|
||||
<div className="relative group flex items-center ml-1">
|
||||
<Link to="/profile">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
|
||||
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
My Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('nav.login', 'LINE Login')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderMobileActionButtons = (includeSearch = true) => (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{includeSearch && (
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderMobileHamburger = () => (
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-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 to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain" /> : brandName}
|
||||
</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 to="/" onClick={() => setMobileOpen(false)} className="text-lg font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-7 object-contain" /> : brandName}
|
||||
</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="ค้นหากลิ่น, หมวดหมู่..."
|
||||
className="w-full rounded-full border border-pop-black/15 bg-pop-black/[0.04] py-1.5 pl-8 pr-3 text-xs text-pop-black placeholder:text-pop-black/40 focus:outline-none focus:ring-1 focus:ring-pop-black"
|
||||
/>
|
||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-pop-black/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</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 to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 object-contain mx-auto" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{renderMobileActionButtons(true)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={`fixed ${finalTopClassName} z-50 w-full transition-all duration-300 ${
|
||||
hideHeader ? 'pointer-events-none -translate-y-full' : 'translate-y-0'
|
||||
} ${
|
||||
scrolled
|
||||
? 'border-b border-pop-black/10 shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90'
|
||||
: shouldBeTransparent
|
||||
? 'border-transparent shadow-none bg-transparent backdrop-blur-none'
|
||||
: 'border-b border-pop-black/10 shadow-none bg-pop-bg'
|
||||
} px-4 py-3.5 md:px-6 md:py-4`}
|
||||
} ${isTwoTier ? 'px-4 py-2.5 md:px-8 md:py-3' : 'px-4 py-3.5 md:px-6 md:py-4'}`}
|
||||
>
|
||||
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
|
||||
<div className="w-full flex items-center justify-between gap-4">
|
||||
{/* Mobile Navigation Bar (< md) */}
|
||||
{renderMobileNavbar()}
|
||||
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Desktop Navigation Bar (>= md) */}
|
||||
{isTwoTier ? (
|
||||
<div className="hidden md:flex w-full flex-col">
|
||||
{/* Top Tier: Logo Centered + Actions 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" />
|
||||
|
||||
{/* Center: Desktop mega menu */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain mx-auto" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{/* Search — desktop & mobile */}
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Bottom Tier: Desktop centered mega menu */}
|
||||
<div className={`hidden md:flex w-full items-center justify-between transition-all duration-300 ${
|
||||
scrolled ? 'py-0.5' : 'border-t border-pop-black/5 pt-2 mt-1'
|
||||
}`}>
|
||||
<div className={`transition-all duration-300 ${scrolled ? 'w-auto opacity-100' : 'w-0 opacity-0 overflow-hidden'}`}>
|
||||
<Link to="/" className="text-sm font-bold tracking-widest text-pop-black uppercase">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-6 w-auto object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/wishlist"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Wishlist"
|
||||
>
|
||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isClientReady && isDesktop && (
|
||||
<>
|
||||
<div className="relative group flex items-center">
|
||||
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||
</button>
|
||||
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||
{LANGUAGES.map(lang => {
|
||||
const isActive = i18n.language.startsWith(lang.code)
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customer ? (
|
||||
<div className="relative group flex items-center ml-1">
|
||||
<Link to="/profile">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
|
||||
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
My Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{t('nav.login', 'LINE Login')}
|
||||
</button>
|
||||
<div className="flex-1 flex justify-center">
|
||||
{isClientReady && isDesktop && (
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div className={`transition-all duration-300 ${scrolled ? 'w-8' : 'w-0'}`} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */
|
||||
<div className="hidden md:flex w-full items-center justify-between gap-4">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center shrink-0">
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
|
||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Desktop mega menu */}
|
||||
{isClientReady && isDesktop && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<DesktopMegaMenu items={megaMenu} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClientReady && !isDesktop && (
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Right: Actions */}
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{isClientReady && !isDesktop && mobileOpen && (
|
||||
|
||||
Reference in new issue
Block a user