423 lines
18 KiB
TypeScript
423 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import Link from './LocalizedLink'
|
|
import { getPathWithoutLocale, getPathWithLocale } from '../lib/locales'
|
|
import { useRouter, usePathname } from 'next/navigation'
|
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
|
import type { Category } from '@homaroma/types'
|
|
import { Check, Globe, Heart, Menu as MenuIcon, ShoppingBag, User, X, Search } from 'lucide-react'
|
|
import { useCartStore } from '../store/cartStore'
|
|
import { useWishlistStore } from '../store/wishlistStore'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { resolveLocalized } from '../lib/localization'
|
|
import { useLang } from '../hooks/useLang'
|
|
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
|
import { MobileDrawer } from './navigation/MobileDrawer'
|
|
import type { MegaMenuPayload } from './navigation/menuTypes'
|
|
import type { StoreSettings } from '@homaroma/types'
|
|
|
|
interface NavbarProps {
|
|
categories?: Category[]
|
|
initialMenu?: MegaMenuPayload[]
|
|
settings?: StoreSettings | null
|
|
}
|
|
|
|
const LANGUAGES = [
|
|
{ code: 'th', label: 'ไทย', short: 'TH' },
|
|
{ code: 'en', label: 'English', short: 'EN' },
|
|
{ code: 'ja', label: '日本語', short: 'JP' },
|
|
{ code: 'zh', label: '简体中文', short: 'ZH' },
|
|
{ code: 'vi', label: 'Tiếng Việt', short: 'VN' },
|
|
{ code: 'ko', label: '한국어', short: 'KO' },
|
|
]
|
|
|
|
function setLanguageCookie(code: string) {
|
|
if (typeof document !== 'undefined') {
|
|
document.cookie = `homaroma_lang=${code}; path=/; max-age=31536000`
|
|
}
|
|
}
|
|
|
|
function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
|
const categoryLinks = categories.map((category) => {
|
|
// category.name is a multilingual Record<lang,string> when fetched with lang=all
|
|
const label: Record<string, string> =
|
|
typeof category.name === 'string'
|
|
? { th: category.name, en: category.name }
|
|
: (category.name as Record<string, string>)
|
|
return { label, url: `/category/${category.id}` }
|
|
})
|
|
|
|
return [
|
|
{ id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' },
|
|
{
|
|
id: 'shop',
|
|
label: { th: 'ร้านค้า', en: 'Shop' },
|
|
url: '/shop',
|
|
columns: categoryLinks.length
|
|
? [
|
|
{
|
|
title: { th: 'คอลเลกชัน', en: 'Collections' },
|
|
links: categoryLinks,
|
|
},
|
|
]
|
|
: undefined,
|
|
},
|
|
{ id: 'promotions', label: { th: 'โปรโมชัน', en: 'Promotions' }, url: '/coupons' },
|
|
{ id: 'scent-guide', label: { th: 'คลาสแนะนำกลิ่น', en: 'Scent Guide' }, url: '/scent-guide' },
|
|
{ id: 'about', label: { th: 'เกี่ยวกับเรา', en: 'About' }, url: '/about' },
|
|
]
|
|
}
|
|
|
|
export default function Navbar({ categories = [], initialMenu = [], settings = null }: NavbarProps) {
|
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [scrolled, setScrolled] = useState(false)
|
|
// Seed from server-fetched initialMenu — no client flash
|
|
const [megaMenu] = useState<MegaMenuPayload[]>(initialMenu)
|
|
const navRef = useRef<HTMLElement>(null)
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
|
|
const { toggleCart, getCartCount } = useCartStore()
|
|
const wishlistCount = useWishlistStore((state) => state.items.length)
|
|
const customer = useAuthStore((state) => state.customer)
|
|
const logout = useAuthStore((state) => state.logout)
|
|
const cartCount = getCartCount()
|
|
const { t, i18n } = useTranslation()
|
|
const lang = useLang()
|
|
|
|
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
|
|
// Use server menu if available, fall back to categories-based menu
|
|
const menuItems = megaMenu.length > 0 ? megaMenu : fallbackMenu
|
|
|
|
const changeLanguage = (code: string) => {
|
|
i18n.changeLanguage(code)
|
|
localStorage.setItem('homaroma_lang', code)
|
|
setLanguageCookie(code)
|
|
|
|
const currentPathWithoutLang = getPathWithoutLocale(pathname)
|
|
const newPath = getPathWithLocale(currentPathWithoutLang, code)
|
|
|
|
setLangSheetOpen(false)
|
|
router.push(newPath + window.location.search)
|
|
}
|
|
|
|
const loginWithLine = () => {
|
|
const maybeWindow = window as Window & { liff?: { login: () => void } }
|
|
if (maybeWindow.liff?.login) {
|
|
maybeWindow.liff.login()
|
|
return
|
|
}
|
|
router.push(getPathWithLocale('/profile', lang))
|
|
}
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => setScrolled(window.scrollY > 20)
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
onScroll()
|
|
return () => window.removeEventListener('scroll', onScroll)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let animationFrameId: number;
|
|
const navElement = navRef.current;
|
|
|
|
const updateNavBottom = () => {
|
|
if (navElement) {
|
|
const bottom = navElement.getBoundingClientRect().bottom
|
|
document.documentElement.style.setProperty('--navbar-bottom', `${bottom}px`)
|
|
}
|
|
}
|
|
|
|
updateNavBottom()
|
|
|
|
window.addEventListener('scroll', updateNavBottom, { passive: true })
|
|
window.addEventListener('resize', updateNavBottom)
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
cancelAnimationFrame(animationFrameId);
|
|
animationFrameId = requestAnimationFrame(updateNavBottom);
|
|
});
|
|
|
|
if (navElement) {
|
|
observer.observe(navElement);
|
|
navElement.addEventListener('transitionend', updateNavBottom);
|
|
}
|
|
observer.observe(document.body);
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', updateNavBottom)
|
|
window.removeEventListener('resize', updateNavBottom)
|
|
observer.disconnect()
|
|
if (navElement) {
|
|
navElement.removeEventListener('transitionend', updateNavBottom);
|
|
}
|
|
cancelAnimationFrame(animationFrameId);
|
|
}
|
|
}, [scrolled])
|
|
|
|
useEffect(() => {
|
|
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
|
|
return () => { document.body.style.overflow = '' }
|
|
}, [searchOpen, mobileOpen, langSheetOpen])
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (searchQuery.trim()) {
|
|
router.push(getPathWithLocale(`/search?q=${encodeURIComponent(searchQuery.trim())}`, lang))
|
|
setSearchOpen(false)
|
|
setMobileOpen(false)
|
|
setSearchQuery('')
|
|
}
|
|
}
|
|
|
|
const iconButtonClass = 'group rounded-full p-2 text-pop-black transition-all hover:bg-pop-pink/10 hover:text-pop-pink md:p-2.5'
|
|
const activeLanguage = LANGUAGES.find((language) => lang.startsWith(language.code) || language.code.startsWith(lang)) ?? LANGUAGES[0]
|
|
const brandName = settings?.brandName || ''
|
|
|
|
return (
|
|
<>
|
|
<nav
|
|
ref={navRef}
|
|
data-scrolled={scrolled}
|
|
className={`global-navbar fixed top-0 z-50 w-full border-b border-black/5 px-4 py-3.5 transition-all duration-300 md:px-6 md:py-4 ${
|
|
scrolled
|
|
? 'bg-white/95 shadow-lg shadow-black/5 backdrop-blur-xl'
|
|
: 'bg-white/80 backdrop-blur-xl md:bg-[#F9F8F5] md:backdrop-blur-none'
|
|
}`}
|
|
>
|
|
<div className="flex w-full items-center justify-between gap-4">
|
|
<div className="flex shrink-0 items-center">
|
|
<Link
|
|
href="/"
|
|
onClick={() => setMobileOpen(false)}
|
|
className="shrink-0 text-xl font-bold uppercase italic tracking-widest text-pop-black md:text-2xl"
|
|
>
|
|
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex flex-1 justify-center">
|
|
<DesktopMegaMenu items={menuItems} lang={lang} currentPath={pathname} />
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1.5 md:gap-2">
|
|
<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-rose-500 text-[9px] font-bold text-white">
|
|
{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-rose-500 text-[9px] font-bold text-white">
|
|
{cartCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<div className="group relative hidden items-center md:flex">
|
|
<button className="flex items-center gap-1.5 rounded-full border border-pop-pink/30 bg-white px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-pop-black transition-all hover:bg-pop-bg hover:text-pop-pink">
|
|
<Globe className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
{activeLanguage.short}
|
|
</button>
|
|
<div className="pointer-events-none absolute right-0 top-full w-36 pt-2 opacity-0 transition-all duration-300 group-hover:pointer-events-auto group-hover:opacity-100">
|
|
<div className="space-y-0.5 overflow-hidden rounded-xl border border-pop-black/10 bg-white/95 p-1.5 shadow-2xl backdrop-blur-xl">
|
|
{LANGUAGES.map((language) => {
|
|
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
|
|
return (
|
|
<button
|
|
key={language.code}
|
|
onClick={() => changeLanguage(language.code)}
|
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-xs font-medium transition-colors ${
|
|
isActive ? 'bg-pop-bg font-bold text-pop-black' : 'text-pop-black/65 hover:bg-pop-bg hover:text-pop-pink'
|
|
}`}
|
|
>
|
|
<span>{language.label}</span>
|
|
{isActive && <Check className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative hidden items-center md:flex">
|
|
{customer ? (
|
|
<Link href="/profile" className="ml-1">
|
|
{customer.pictureUrl ? (
|
|
<img
|
|
src={customer.pictureUrl}
|
|
alt={customer.displayName || 'Profile'}
|
|
className="h-9 w-9 rounded-full border-2 border-pop-pink/30 object-cover"
|
|
/>
|
|
) : (
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-full border-2 border-pop-pink/30 text-pop-black/65">
|
|
<User className="h-4 w-4" />
|
|
</span>
|
|
)}
|
|
</Link>
|
|
) : (
|
|
<button
|
|
onClick={loginWithLine}
|
|
className="ml-2 rounded-full bg-[#00B900] px-4 py-2 text-xs font-bold uppercase tracking-widest text-white shadow-sm transition-all duration-300 hover:bg-[#009900]"
|
|
>
|
|
{t('nav.login', 'เข้าสู่ระบบด้วย LINE')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setMobileOpen((prev) => !prev)}
|
|
className={`${iconButtonClass} md:hidden`}
|
|
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
|
>
|
|
{mobileOpen ? <X className="h-5 w-5" /> : <MenuIcon className="h-5 w-5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<MobileDrawer
|
|
isOpen={mobileOpen}
|
|
items={menuItems}
|
|
lang={lang}
|
|
customerName={customer?.displayName}
|
|
onClose={() => setMobileOpen(false)}
|
|
onOpenLang={() => setLangSheetOpen(true)}
|
|
onLogin={loginWithLine}
|
|
onLogout={logout}
|
|
/>
|
|
|
|
{searchOpen && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex flex-col items-center bg-pop-bg/95 px-4 pt-24 backdrop-blur-xl md:px-6 md:pt-32"
|
|
onClick={(e) => { if (e.target === e.currentTarget) setSearchOpen(false) }}
|
|
>
|
|
<div className="w-full max-w-2xl space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-pop-black/55">{t('nav.search.title')}</p>
|
|
<button onClick={() => setSearchOpen(false)} className="rounded-full bg-white p-2 text-pop-black/65 transition hover:text-pop-pink">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSearch}>
|
|
<div className="flex overflow-hidden rounded-2xl border-2 border-pop-black/10 bg-white shadow-sm transition-colors focus-within:border-pop-pink">
|
|
<Search className="m-4 h-5 w-5 shrink-0 text-pop-black/35" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('nav.search.placeholder')}
|
|
className="w-full border-none bg-transparent p-4 pl-0 text-lg text-pop-black outline-none placeholder:text-pop-black/35 md:text-xl"
|
|
autoFocus
|
|
/>
|
|
<button type="submit" className="shrink-0 bg-pop-black px-4 text-xs font-bold uppercase tracking-widest text-white transition-colors hover:bg-pop-pink md:px-6">
|
|
Go
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div>
|
|
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-pop-black/55">{t('nav.search.quickLinks')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
...categories.map((category) => ({ label: resolveLocalized(category.name, lang), to: `/category/${category.id}` })),
|
|
{ label: t('nav.scentGuide'), to: '/scent-guide' },
|
|
{ label: t('nav.search.allProducts'), to: '/shop' },
|
|
].map((item) => (
|
|
<Link
|
|
key={item.to}
|
|
href={item.to}
|
|
onClick={() => setSearchOpen(false)}
|
|
className="rounded-full border border-pop-black/10 bg-white px-4 py-1.5 text-xs font-semibold text-pop-black/65 transition-all hover:border-pop-pink hover:text-pop-pink"
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`fixed inset-0 z-[110] transition-all duration-300 md:hidden ${langSheetOpen ? 'visible' : 'invisible'}`}>
|
|
<div
|
|
className={`absolute inset-0 bg-pop-black/40 backdrop-blur-sm transition-opacity duration-300 ${langSheetOpen ? 'opacity-100' : 'opacity-0'}`}
|
|
onClick={() => setLangSheetOpen(false)}
|
|
/>
|
|
<div
|
|
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl bg-white shadow-2xl transition-transform duration-300 ${
|
|
langSheetOpen ? 'translate-y-0' : 'translate-y-full'
|
|
}`}
|
|
>
|
|
<div className="flex justify-center pb-1 pt-3">
|
|
<div className="h-1.5 w-12 rounded-full bg-pop-black/15" />
|
|
</div>
|
|
<div className="px-6 pb-6 pt-2">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-widest text-pop-black">
|
|
<Globe className="h-4 w-4" />
|
|
Select Language
|
|
</h3>
|
|
<button onClick={() => setLangSheetOpen(false)} className="rounded-full p-2 text-pop-black/65 hover:bg-pop-bg">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{LANGUAGES.map((language) => {
|
|
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
|
|
return (
|
|
<button
|
|
key={language.code}
|
|
onClick={() => changeLanguage(language.code)}
|
|
className={`flex w-full items-center justify-between rounded-xl border border-transparent px-4 py-4 font-medium transition-colors ${
|
|
isActive ? 'border-pop-pink/20 bg-pop-bg font-bold text-pop-black' : 'bg-pop-bg/40 text-pop-black/65 hover:bg-pop-bg'
|
|
}`}
|
|
>
|
|
<span className="text-base">{language.label}</span>
|
|
{isActive && <Check className="h-5 w-5" />}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|