This commit is contained in:
Your Name committed 2026-06-17 08:34:58 -04:00
1 parent 7b98d41555
commit 996dfdabd0
10 files changed
+934 -227

No files matched your search

+276 -218
View File
@@ -1,59 +1,155 @@
"use client";
"use client"
import Link from 'next/link'
import { useRouter, usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import type { Category } from '@homaroma/types'
import { Heart, ShoppingBag, MapPin, User } from 'lucide-react'
import { Check, Globe, Heart, Menu as MenuIcon, ShoppingBag, User, X } 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 { API_BASE } from '../lib/api'
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
import { MobileDrawer } from './navigation/MobileDrawer'
import type { MegaMenuPayload } from './navigation/menuTypes'
interface NavbarProps {
categories?: Category[]
}
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 createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
const categoryLinks = categories.map((category) => ({
label: {
th: resolveLocalized(category.name, 'th') || resolveLocalized(category.name),
en: resolveLocalized(category.name, 'en') || resolveLocalized(category.name),
},
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 = [] }: 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)
const [localOrdersCount, setLocalOrdersCount] = useState(0)
const [megaMenu, setMegaMenu] = useState<MegaMenuPayload[]>([])
const [menuLoaded, setMenuLoaded] = useState(false)
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 = i18n.language ? i18n.language.substring(0, 2) : 'th'
const toggleLanguage = () => {
const newLang = i18n.language.startsWith('en') ? 'th' : 'en'
i18n.changeLanguage(newLang)
localStorage.setItem('homaroma_lang', newLang)
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
const menuItems = menuLoaded && megaMenu.length > 0 ? megaMenu : fallbackMenu
const changeLanguage = (code: string) => {
i18n.changeLanguage(code)
localStorage.setItem('homaroma_lang', code)
setLangSheetOpen(false)
}
const loginWithLine = () => {
const maybeWindow = window as Window & { liff?: { login: () => void } }
if (maybeWindow.liff?.login) {
maybeWindow.liff.login()
return
}
router.push('/profile')
}
useEffect(() => {
let isMounted = true
async function fetchMegaMenu() {
try {
const response = await fetch(`${API_BASE}/menus/main`)
if (!response.ok) throw new Error(`menus/main returned ${response.status}`)
const data = await response.json() as { payload?: MegaMenuPayload[] }
if (isMounted) {
setMegaMenu(Array.isArray(data.payload) ? data.payload : [])
setMenuLoaded(true)
}
} catch (error) {
console.error('[website-menu] Failed to fetch main menu', error)
if (isMounted) setMenuLoaded(true)
}
}
fetchMegaMenu()
return () => {
isMounted = false
}
}, [])
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20)
window.addEventListener('scroll', onScroll, { passive: true })
const stored = localStorage.getItem('homaroma_local_orders')
if (stored) {
try {
const parsed = JSON.parse(stored) as unknown[]
queueMicrotask(() => setLocalOrdersCount(parsed.length))
} catch {}
}
onScroll()
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
const updateNavBottom = () => {
if (navRef.current) {
const bottom = navRef.current.getBoundingClientRect().bottom
document.documentElement.style.setProperty('--navbar-bottom', `${bottom}px`)
}
}
updateNavBottom()
window.addEventListener('scroll', updateNavBottom, { passive: true })
window.addEventListener('resize', updateNavBottom)
return () => {
window.removeEventListener('scroll', updateNavBottom)
window.removeEventListener('resize', updateNavBottom)
}
}, [])
useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [searchOpen, mobileOpen])
}, [searchOpen, mobileOpen, langSheetOpen])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
@@ -65,70 +161,55 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}
}
const navLinkClass = (path: string) =>
pathname === path
? 'text-pop-pink'
: 'text-pop-black/75 transition-colors hover:text-pop-pink'
const iconButtonClass = 'rounded-full border border-gray-200 bg-white p-2.5 text-pop-black shadow-sm transition hover:border-pop-pink hover:text-pop-pink hover:shadow-brutal'
const iconButtonClass = 'rounded-full p-1.5 text-pop-black/65 transition-all hover:bg-pop-bg hover:text-pop-pink md:p-2'
const activeLanguage = LANGUAGES.find((language) => i18n.language.startsWith(language.code)) ?? LANGUAGES[0]
return (
<>
<nav
className={`fixed top-0 z-50 flex w-full items-center justify-between px-4 py-3.5 transition-all duration-300 md:px-6 md:py-4 ${
ref={navRef}
className={`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
? 'border-b border-gray-200 bg-white/95 shadow-brutal backdrop-blur-xl'
: 'border-b border-gray-100 bg-white/85 backdrop-blur-xl'
? 'bg-white/95 shadow-lg shadow-black/5 backdrop-blur-xl'
: 'bg-white/80 backdrop-blur-xl md:bg-[#F9F8F5] md:backdrop-blur-none'
}`}
>
<Link href="/" onClick={() => setMobileOpen(false)} className="shrink-0 text-xl font-bold uppercase italic tracking-widest text-pop-black md:text-2xl">
HomAroma <span className="text-xs font-semibold not-italic text-pop-pink md:text-sm">Essential</span>
<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"
>
HomAroma <span className="hidden text-xs font-light not-italic text-pop-black/55 md:inline md:text-sm">Essential</span>
</Link>
</div>
<div className="hidden space-x-7 text-sm font-semibold uppercase tracking-wide md:flex">
<Link href="/" className={navLinkClass('/')}>{t('nav.home')}</Link>
<Link href="/shop" className={navLinkClass('/shop')}>{t('nav.shop')}</Link>
<Link href="/coupons" className={navLinkClass('/coupons')}>🔥 {t('nav.promotions') || 'Promotions'}</Link>
<div className="flex flex-1 justify-center">
<DesktopMegaMenu items={menuItems} lang={lang} currentPath={pathname} />
</div>
{categories.length > 0 && (
<div className="group relative">
<button className="flex items-center gap-1 text-sm font-semibold uppercase tracking-wide text-pop-black/75 transition-colors hover:text-pop-pink">
{t('nav.collections')}
<svg className="h-3 w-3 opacity-60 transition-transform duration-300 group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<div className="flex shrink-0 items-center gap-1.5 md:gap-2">
<button
onClick={() => {
setSearchOpen(true)
setMobileOpen(false)
}}
className={iconButtonClass}
aria-label="Search"
>
<svg className="h-4 w-4 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 0114 0z" />
</svg>
</button>
<div className="pointer-events-none absolute left-1/2 top-full mt-4 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-gray-200 bg-white opacity-0 shadow-brutal transition-all duration-300 group-hover:pointer-events-auto group-hover:opacity-100">
<div className="space-y-0.5 p-2">
{categories.map((cat) => (
<Link
key={cat.id}
href={`/category/${cat.id}`}
className="flex items-center justify-between rounded-xl px-4 py-2.5 text-xs uppercase tracking-widest text-gray-600 transition-colors hover:bg-pop-bg hover:text-pop-pink"
href="/wishlist"
onClick={() => setMobileOpen(false)}
className={`relative ${iconButtonClass}`}
aria-label="Wishlist"
>
{resolveLocalized(cat.name)}
<svg className="h-3 w-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
</div>
</div>
)}
<Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link>
<Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link>
</div>
<div className="hidden items-center gap-3 md:flex">
<Link href="/track" className={`relative ${iconButtonClass}`} aria-label="Track Orders" title="Track Orders">
<MapPin className="h-4 w-4" />
{localOrdersCount > 0 && <span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-rose-500" />}
</Link>
<Link href="/wishlist" className={`relative ${iconButtonClass}`} aria-label="Wishlist" title="Wishlist">
<Heart className="h-4 w-4" />
<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}
@@ -136,159 +217,94 @@ export default function Navbar({ categories = [] }: NavbarProps) {
)}
</Link>
<Link href="/profile" className={iconButtonClass} aria-label="Profile" title={customer?.displayName || 'Profile'}>
{customer?.pictureUrl ? (
<img src={customer.pictureUrl} alt={customer.displayName || 'Profile'} className="h-4 w-4 rounded-full object-cover" />
) : (
<User className="h-4 w-4" />
)}
</Link>
<button onClick={() => setSearchOpen(true)} className={iconButtonClass} aria-label="Search">
<svg className="h-4 w-4" 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>
<button onClick={() => toggleCart(true)} className={`relative ${iconButtonClass}`} aria-label="Cart">
<ShoppingBag className="h-4 w-4" />
<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-pop-pink text-[9px] font-bold text-white">
<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>
<Link href="/shop" className="rounded-full bg-pop-black px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
{t('nav.shopNow')}
</Link>
<button onClick={toggleLanguage} className="rounded-full border border-pop-pink/30 bg-white px-3 py-2 text-xs font-bold uppercase text-pop-pink transition hover:bg-pop-bg">
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
<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 = i18n.language.startsWith(language.code)
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="flex items-center gap-2 md:hidden">
<Link href="/track" onClick={() => setMobileOpen(false)} className={`relative ${iconButtonClass}`} aria-label="Track Orders">
<MapPin className="h-4 w-4" />
{localOrdersCount > 0 && <span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-rose-500" />}
</Link>
<Link href="/wishlist" onClick={() => setMobileOpen(false)} className={`relative ${iconButtonClass}`} aria-label="Wishlist">
<Heart className="h-4 w-4" />
{wishlistCount > 0 && (
<span className="absolute right-0 top-0 flex h-4 w-4 translate-x-1/4 -translate-y-1/4 items-center justify-center rounded-full bg-rose-500 text-[9px] font-bold text-white">
{wishlistCount}
</span>
)}
</Link>
<Link href="/profile" onClick={() => setMobileOpen(false)} className={iconButtonClass} aria-label="Profile">
{customer?.pictureUrl ? (
<img src={customer.pictureUrl} alt={customer.displayName || 'Profile'} className="h-4 w-4 rounded-full object-cover" />
<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" />
)}
</Link>
<button onClick={() => { setSearchOpen(true); setMobileOpen(false) }} className={iconButtonClass} aria-label="Search">
<svg className="h-5 w-5" 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>
<button onClick={() => { toggleCart(true); setMobileOpen(false) }} className={`relative ${iconButtonClass}`} aria-label="Cart">
<ShoppingBag className="h-5 w-5" />
{cartCount > 0 && (
<span className="absolute right-0 top-0 flex h-4 w-4 translate-x-1/4 -translate-y-1/4 items-center justify-center rounded-full bg-pop-pink text-[9px] font-bold text-white">
{cartCount}
</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]"
>
LINE Login
</button>
<button onClick={() => setMobileOpen(prev => !prev)} className={iconButtonClass} aria-label={mobileOpen ? 'Close menu' : 'Open menu'}>
<div className="relative flex h-5 w-5 flex-col justify-center gap-[5px]">
<span className={`block h-0.5 w-5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
<span className={`block h-0.5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'w-0 opacity-0' : 'w-4'}`} />
<span className={`block h-0.5 rounded-full bg-pop-black transition-all duration-300 ${mobileOpen ? 'absolute w-5 -rotate-45' : 'w-5'}`} />
)}
</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>
<div className={`fixed inset-0 z-40 transition-all duration-300 md:hidden ${mobileOpen ? 'visible' : 'invisible'}`}>
<div
className={`absolute inset-0 bg-pop-black/45 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={() => setMobileOpen(false)}
<MobileDrawer
isOpen={mobileOpen}
items={menuItems}
lang={lang}
customerName={customer?.displayName}
onClose={() => setMobileOpen(false)}
onOpenLang={() => setLangSheetOpen(true)}
onLogin={loginWithLine}
onLogout={logout}
/>
<div
className={`absolute right-0 top-0 flex h-full w-72 flex-col border-l border-gray-200 bg-white shadow-2xl transition-transform duration-300 ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`}
>
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<span className="text-sm font-bold uppercase tracking-widest text-pop-black">{t('nav.menu')}</span>
<div className="flex items-center gap-3">
<button onClick={toggleLanguage} className="rounded-md border border-pop-pink/30 px-2.5 py-1 text-[10px] font-bold uppercase text-pop-pink">
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button>
<button onClick={() => setMobileOpen(false)} className="rounded-full border border-gray-200 p-1.5 text-pop-black transition hover:text-pop-pink">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="flex-1 space-y-1 overflow-y-auto px-4 py-6">
{[
{ to: '/', label: t('nav.home') },
{ to: '/shop', label: t('nav.shop') },
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/wishlist', label: '♡ Wishlist' },
{ to: '/profile', label: customer ? `👤 ${customer.displayName || 'Profile'}` : '👤 Profile' },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
{ to: '/about', label: t('nav.about') },
{ to: '/faq', label: t('nav.faq') },
{ to: '/contact', label: t('nav.contact') },
].map(item => (
<Link
key={item.to}
href={item.to}
onClick={() => setMobileOpen(false)}
className={`block rounded-xl px-4 py-3 text-sm font-semibold tracking-wide transition-all duration-200 ${
pathname === item.to ? 'border border-pop-pink/20 bg-pop-bg text-pop-pink' : 'text-pop-black/75 hover:bg-pop-bg hover:text-pop-pink'
}`}
>
{item.label}
</Link>
))}
{categories.length > 0 && (
<div className="pt-4">
<p className="px-4 pb-2 text-[10px] uppercase tracking-widest text-gray-500">{t('nav.collections')}</p>
{categories.map(cat => (
<Link
key={cat.id}
href={`/category/${cat.id}`}
onClick={() => setMobileOpen(false)}
className="flex items-center justify-between rounded-xl px-4 py-2.5 text-sm text-gray-600 transition-all hover:bg-pop-bg hover:text-pop-pink"
>
{resolveLocalized(cat.name)}
<svg className="h-3 w-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
)}
</div>
<div className="space-y-3 border-t border-gray-100 px-4 pb-8 pt-4">
<Link href="/shop" onClick={() => setMobileOpen(false)} className="block w-full rounded-xl bg-pop-black py-3 text-center text-xs font-bold uppercase tracking-widest text-white transition hover:bg-pop-pink">
{t('nav.shopNow')}
</Link>
<Link href="/scent-guide" onClick={() => setMobileOpen(false)} className="block w-full rounded-xl border border-pop-pink/30 bg-white py-3 text-center text-xs font-bold uppercase tracking-widest text-pop-pink transition hover:bg-pop-bg">
{t('nav.findMyScent')}
</Link>
</div>
</div>
</div>
{searchOpen && (
<div
@@ -297,25 +313,23 @@ export default function Navbar({ categories = [] }: NavbarProps) {
>
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-widest text-gray-500">{t('nav.search.title')}</p>
<button onClick={() => setSearchOpen(false)} className="rounded-full border border-gray-200 bg-white p-2 text-pop-black transition hover:text-pop-pink">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<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 border-gray-200 bg-white shadow-brutal transition-colors focus-within:border-pop-pink">
<svg className="m-4 h-5 w-5 shrink-0 text-gray-400" 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" />
<div className="flex overflow-hidden rounded-2xl border-2 border-pop-black/10 bg-white shadow-sm transition-colors focus-within:border-pop-pink">
<svg className="m-4 h-5 w-5 shrink-0 text-pop-black/35" 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 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search scents, categories..."
className="w-full border-none bg-transparent p-4 pl-0 text-lg text-pop-black outline-none placeholder:text-gray-400 md:text-xl"
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">
@@ -325,18 +339,18 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</form>
<div>
<p className="mb-3 text-xs uppercase tracking-widest text-gray-500">Quick Links</p>
<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(c => ({ label: resolveLocalized(c.name), to: `/category/${c.id}` })),
{ label: '✨ Scent Guide', to: '/scent-guide' },
{ label: 'All Products', to: '/shop' },
].map(item => (
...categories.map((category) => ({ label: resolveLocalized(category.name), 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-gray-200 bg-white px-4 py-1.5 text-xs font-semibold text-gray-600 transition-all hover:border-pop-pink hover:text-pop-pink"
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>
@@ -346,6 +360,50 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</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 = i18n.language.startsWith(language.code)
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>
</>
)
}
@@ -49,6 +49,19 @@ function renderAccentTitle(text: string, accentClassName = 'text-pop-pink italic
})
}
function heroLearnMoreText(lang: string) {
const labels: Record<string, string> = {
th: 'ดูรายละเอียดเพิ่มเติม',
en: 'Learn More',
ja: '詳しく見る',
zh: '了解更多',
vi: 'Tìm hiểu thêm',
ko: '자세히 보기',
}
return labels[lang] ?? labels.en
}
function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const slides = Array.isArray(payload.slides) ? payload.slides : []
if (!slides.length) return null
@@ -74,31 +87,44 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
return (
<section className="relative flex min-h-[78vh] w-full items-center justify-center overflow-hidden bg-white">
<section className="relative flex min-h-[90vh] w-full items-center justify-center overflow-hidden bg-neutral-900 md:aspect-[21/9] md:min-h-0">
<img
src={imageUrl}
alt={title || 'HomAroma'}
className="absolute inset-0 h-full w-full scale-[1.02] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-pop-bg/90 via-pop-bg/55 to-white/10" />
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-24">
<div className="max-w-3xl space-y-6">
<div className="absolute inset-0 bg-neutral-900/40" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30" />
<div className="relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0">
<div className="space-y-6">
{tagline && (
<p className="text-xs font-bold uppercase tracking-[0.28em] text-pop-pink">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white drop-shadow-md md:text-sm">
{tagline}
</p>
)}
<h1 className="text-5xl font-semibold leading-tight text-pop-black md:text-7xl">
{title || 'HomAroma'}
<h1 className="font-serif text-5xl font-light leading-tight tracking-wide text-white drop-shadow-md md:text-7xl lg:text-8xl">
{renderAccentTitle(title || 'HomAroma', 'font-medium italic text-[#E4BC32]')}
</h1>
{description && (
<p className="max-w-2xl text-lg leading-relaxed text-gray-700 md:text-xl">
<p className="mx-auto max-w-2xl pb-8 pt-4 text-lg font-light text-white/90 drop-shadow-md md:text-xl">
{description}
</p>
)}
<Link href={buttonLink} className="inline-flex rounded-full bg-pop-pink px-8 py-4 font-semibold text-white shadow-brutal transition hover:bg-pop-red">
<div className="flex flex-col items-center justify-center gap-6 sm:flex-row">
<Link
href={buttonLink}
className="rounded-full bg-white px-8 py-4 text-sm font-bold uppercase tracking-widest text-pop-black transition-all duration-300 hover:bg-pop-bg hover:shadow-xl"
>
{buttonText || 'Explore Collection'}
</Link>
<a
href="#categories"
className="group flex items-center gap-2 px-8 py-4 text-sm font-medium uppercase tracking-widest text-white transition-colors hover:text-[#E4BC32]"
>
{heroLearnMoreText(lang)}
<span className="transition-transform group-hover:translate-x-1">→</span>
</a>
</div>
</div>
</div>
</section>
@@ -0,0 +1,102 @@
"use client"
import Link from 'next/link'
import { useState, useRef } from 'react'
import type { MegaMenuPayload } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes'
interface DesktopMegaMenuProps {
items: MegaMenuPayload[]
lang: string
currentPath: string
}
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleOpen = (id: string) => {
if (closeTimer.current) clearTimeout(closeTimer.current)
setOpenId(id)
}
const handleClose = () => {
closeTimer.current = setTimeout(() => setOpenId(null), 150)
}
return (
<div className="hidden space-x-7 text-sm font-medium uppercase tracking-wide md:flex">
{items.map((item, index) => {
const uniqueId = item.nodeId || item.id || `menu-${item.url || index}`
const isOpen = openId === uniqueId
const hasColumns = !!item.columns?.length
const isActive = item.url ? currentPath === item.url : false
return (
<div
key={uniqueId}
className="relative"
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<Link
href={item.url || '#'}
className={`flex items-center gap-1 border-b py-2 transition-colors ${
isOpen || isActive
? 'border-pop-pink text-pop-black'
: 'border-transparent text-pop-black/65 hover:border-pop-pink hover:text-pop-black'
}`}
>
{getLocalizedMenuText(item.label, lang)}
{hasColumns && (
<svg
className={`h-3 w-3 opacity-50 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</Link>
{hasColumns && (
<div
className={`fixed left-0 right-0 z-50 transition-opacity duration-200 ${
isOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="w-full cursor-default border-b border-black/5 bg-white/95 shadow-brutal backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl justify-center gap-16 px-6 py-10">
{item.columns!.map((column, columnIndex) => (
<div key={columnIndex} className="flex min-w-[140px] flex-col text-left">
<h4 className="mb-4 text-[10px] font-bold uppercase tracking-widest text-pop-black/50">
{getLocalizedMenuText(column.title, lang)}
</h4>
<ul className="space-y-3">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.url || '#'}
className="block text-sm font-normal normal-case tracking-normal text-pop-black/65 transition-colors hover:text-pop-pink"
>
{getLocalizedMenuText(link.label, lang)}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,245 @@
"use client"
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { ChevronLeft, ChevronRight, Globe, X } from 'lucide-react'
import type { MegaMenuPayload, MenuColumn, MenuLink } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes'
type MenuNode =
| { type: 'root'; data: MegaMenuPayload[]; title: string | Record<string, string> }
| { type: 'mega'; data: MenuColumn[]; title: string | Record<string, string> }
| { type: 'links'; data: MenuLink[]; title: string | Record<string, string> }
interface MobileDrawerProps {
isOpen: boolean
items: MegaMenuPayload[]
lang: string
customerName?: string | null
onClose: () => void
onOpenLang: () => void
onLogin: () => void
onLogout: () => void
}
export function MobileDrawer({
isOpen,
items,
lang,
customerName,
onClose,
onOpenLang,
onLogin,
onLogout,
}: MobileDrawerProps) {
const [stack, setStack] = useState<MenuNode[]>([{ type: 'root', data: items, title: 'Menu' }])
useEffect(() => {
if (!isOpen) {
const timer = setTimeout(() => {
setStack([{ type: 'root', data: items, title: 'Menu' }])
}, 300)
return () => clearTimeout(timer)
}
const timer = setTimeout(() => {
setStack((current) => {
if (current.length === 1 && current[0].data !== items) {
return [{ type: 'root', data: items, title: 'Menu' }]
}
return current
})
}, 0)
return () => clearTimeout(timer)
}, [isOpen, items])
const pushView = (node: MenuNode) => setStack((prev) => [...prev, node])
const popView = () => setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
const linkClass = 'block w-full py-4 text-left text-sm font-medium uppercase tracking-wide text-pop-black/65 transition-colors hover:text-pop-pink'
const nestedLinkClass = 'block w-full py-2 text-left text-xs uppercase tracking-widest text-pop-black/65 transition-colors hover:text-pop-pink'
const renderView = (view: MenuNode) => {
if (view.type === 'root') {
return (
<div className="space-y-1 px-6 py-2">
{view.data.map((item, index) => (
<div key={item.id || item.nodeId || `menu-root-${index}`} className="border-b border-pop-black/10 last:border-0">
{item.columns?.length ? (
<button
onClick={() => pushView({ type: 'mega', data: item.columns!, title: item.label })}
className="group flex w-full items-center justify-between py-4 text-sm font-medium uppercase tracking-wide text-pop-black/65 transition-colors hover:text-pop-pink"
>
<span>{getLocalizedMenuText(item.label, lang)}</span>
<ChevronRight className="h-4 w-4 text-pop-black/35 transition-colors group-hover:text-pop-pink" />
</button>
) : (
<Link href={item.url || '#'} onClick={onClose} className={linkClass}>
{getLocalizedMenuText(item.label, lang)}
</Link>
)}
</div>
))}
{customerName && (
<div className="border-b border-pop-black/10">
<Link href="/profile" onClick={onClose} className={linkClass}>
My Profile
</Link>
</div>
)}
</div>
)
}
if (view.type === 'mega') {
return (
<div className="px-6 py-4">
{view.data.map((column, columnIndex) => (
<div key={columnIndex} className="mb-8 last:mb-0">
<h4 className="mb-4 text-[10px] font-bold uppercase tracking-widest text-pop-black/45">
{getLocalizedMenuText(column.title, lang)}
</h4>
<div className="space-y-2">
{column.links.map((link, linkIndex) => (
<div key={linkIndex}>
{link.links?.length ? (
<button
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
className="group flex w-full items-center justify-between py-2 text-xs uppercase tracking-widest text-pop-black/65 transition-colors hover:text-pop-pink"
>
<span>{getLocalizedMenuText(link.label, lang)}</span>
<ChevronRight className="h-4 w-4 text-pop-black/35 transition-colors group-hover:text-pop-pink" />
</button>
) : (
<Link href={link.url || '#'} onClick={onClose} className={nestedLinkClass}>
{getLocalizedMenuText(link.label, lang)}
</Link>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
return (
<div className="space-y-2 px-6 py-4">
{view.data.map((link, linkIndex) => (
<div key={linkIndex}>
{link.links?.length ? (
<button
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
className="group flex w-full items-center justify-between py-2 text-xs uppercase tracking-widest text-pop-black/65 transition-colors hover:text-pop-pink"
>
<span>{getLocalizedMenuText(link.label, lang)}</span>
<ChevronRight className="h-4 w-4 text-pop-black/35 transition-colors group-hover:text-pop-pink" />
</button>
) : (
<Link href={link.url || '#'} onClick={onClose} className={nestedLinkClass}>
{getLocalizedMenuText(link.label, lang)}
</Link>
)}
</div>
))}
</div>
)
}
return (
<div className={`fixed inset-0 z-[60] transition-all duration-300 md:hidden ${isOpen ? 'visible' : 'invisible'}`}>
<div
className={`absolute inset-0 bg-pop-black/40 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
/>
<div
className={`absolute right-0 top-0 flex h-full w-80 max-w-[85vw] flex-col bg-white shadow-2xl transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="z-10 flex shrink-0 items-center justify-between border-b border-pop-black/10 bg-white px-6 py-5">
<span className="text-sm font-bold uppercase tracking-widest text-pop-black">Menu</span>
<div className="flex items-center gap-3">
<button
onClick={onOpenLang}
className="flex shrink-0 items-center gap-1 rounded-md border border-pop-pink/30 bg-pop-bg px-2.5 py-1 text-[10px] font-bold uppercase text-pop-pink"
>
<Globe className="h-3 w-3" />
{lang.toUpperCase()}
</button>
<button onClick={onClose} className="rounded-full p-1.5 text-pop-black/65 transition hover:bg-pop-bg hover:text-pop-pink">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="relative flex-1 overflow-hidden bg-white">
{stack.map((view, index) => {
const isCurrent = index === stack.length - 1
const isPast = index < stack.length - 1
return (
<div
key={index}
className={`absolute inset-0 flex flex-col bg-white transition-transform duration-300 ease-in-out ${
isCurrent ? 'translate-x-0' : isPast ? '-translate-x-full' : 'translate-x-full'
}`}
>
{index > 0 && (
<button
onClick={popView}
className="flex shrink-0 items-center border-b border-pop-black/5 px-6 py-4 text-xs font-bold uppercase tracking-widest text-pop-pink transition hover:bg-pop-bg"
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to {getLocalizedMenuText(stack[index - 1].title, lang)}
</button>
)}
{index > 0 && (
<div className="shrink-0 px-6 py-5">
<h3 className="text-lg font-bold uppercase tracking-widest text-pop-black">{getLocalizedMenuText(view.title, lang)}</h3>
</div>
)}
<div className="flex-1 overflow-y-auto pb-6">{renderView(view)}</div>
</div>
)
})}
</div>
<div className="z-10 shrink-0 space-y-3 border-t border-pop-black/10 bg-white px-6 pb-8 pt-6">
<Link
href="/shop"
onClick={onClose}
className="block w-full rounded-xl bg-pop-black py-3.5 text-center text-xs font-bold uppercase tracking-widest text-white shadow-sm transition-colors hover:bg-pop-pink"
>
Shop Now
</Link>
{customerName ? (
<button
onClick={() => {
onLogout()
onClose()
}}
className="block w-full rounded-xl border border-rose-500/20 py-3.5 text-center text-xs font-bold uppercase tracking-widest text-rose-500 transition-colors hover:bg-rose-50"
>
Logout
</button>
) : (
<button
onClick={() => {
onLogin()
onClose()
}}
className="block w-full rounded-xl border border-[#00B900]/50 py-3.5 text-center text-xs font-bold uppercase tracking-widest text-[#00B900] transition-colors hover:bg-[#00B900]/10"
>
Login with LINE
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
export interface MenuLink {
label: Record<string, string> | string
url?: string
links?: MenuLink[]
}
export interface MenuColumn {
title: Record<string, string> | string
links: MenuLink[]
}
export interface MegaMenuPayload {
id?: string
nodeId?: string
label: Record<string, string> | string
url?: string
columns?: MenuColumn[]
}
export function getLocalizedMenuText(text: Record<string, string> | string | undefined, lang: string) {
if (typeof text === 'string') return text
if (!text) return ''
return text[lang] || text.en || text.th || Object.values(text)[0] || ''
}
+8
View File
@@ -3,6 +3,10 @@ import { initReactI18next } from 'react-i18next'
import enTranslation from '../locales/en/translation.json'
import thTranslation from '../locales/th/translation.json'
import jaTranslation from '../locales/ja/translation.json'
import zhTranslation from '../locales/zh/translation.json'
import viTranslation from '../locales/vi/translation.json'
import koTranslation from '../locales/ko/translation.json'
// Ensure we only access localStorage on the client side
const savedLang = typeof window !== 'undefined' ? (localStorage.getItem('homaroma_lang') || 'th') : 'th'
@@ -15,6 +19,10 @@ if (!i18n.isInitialized) {
resources: {
en: { translation: enTranslation },
th: { translation: thTranslation },
ja: { translation: jaTranslation },
zh: { translation: zhTranslation },
vi: { translation: viTranslation },
ko: { translation: koTranslation },
},
lng: savedLang, // default language
fallbackLng: 'en',
@@ -0,0 +1,61 @@
{
"nav": {
"home": "ホーム",
"shop": "ショップ",
"collections": "コレクション",
"scentGuide": "香りガイド",
"about": "私たちについて",
"menu": "メニュー",
"faq": "よくある質問",
"contact": "お問い合わせ",
"shopNow": "今すぐ購入",
"findMyScent": "香りを探す ✨",
"promotions": "プロモーション",
"profile": "マイプロフィール",
"search": {
"title": "検索",
"placeholder": "香りを検索...",
"quickLinks": "クイックリンク",
"allProducts": "すべての商品"
},
"trackOrder": "注文追跡",
"delivery": "配送と返品"
},
"cart": {
"title": "カート",
"items": "{{count}} 点",
"empty": "カートは空です。",
"continueShopping": "ショッピングを続ける",
"subtotal": "小計",
"shippingNote": "送料と税金はチェックアウト時に計算されます。",
"proceed": "レジへ進む",
"addToCart": "カートに追加",
"totalPrice": "合計金額"
},
"footer": {
"catchphrase": "プレミアムな香りで空間をアップグレード",
"explore": "探索",
"support": "サポート",
"followUs": "フォローする",
"contactUs": "お問い合わせ",
"rights": "All rights reserved."
},
"common": {
"home": "ホーム",
"shop": "ショップ",
"viewAll": "すべて見る →",
"backToShop": "ショップに戻る"
},
"product": {
"notFound": "商品が見つかりません",
"premiumCollection": "プレミアムコレクション",
"selectSize": "サイズを選択",
"details": "商品詳細",
"scent": "香り",
"category": "カテゴリー",
"relatedProducts": "関連商品",
"moreRelated": "関連する {{category}}",
"reviews": "レビュー",
"viewDetails": "詳細を見る →"
}
}
@@ -0,0 +1,61 @@
{
"nav": {
"home": "홈",
"shop": "쇼핑",
"collections": "컬렉션",
"scentGuide": "향기 가이드",
"about": "소개",
"menu": "메뉴",
"faq": "자주 묻는 질문",
"contact": "연락처",
"shopNow": "지금 쇼핑하기",
"findMyScent": "나의 향기 찾기 ✨",
"promotions": "프로모션",
"profile": "내 프로필",
"search": {
"title": "검색",
"placeholder": "향기 검색...",
"quickLinks": "빠른 링크",
"allProducts": "모든 제품"
},
"trackOrder": "주문 배송조회",
"delivery": "배송 및 반품"
},
"cart": {
"title": "장바구니",
"items": "{{count}} 개",
"empty": "장바구니가 비어 있습니다.",
"continueShopping": "쇼핑 계속하기",
"subtotal": "소계",
"shippingNote": "배송비 및 세금은 결제 시 계산됩니다.",
"proceed": "결제 진행",
"addToCart": "장바구니 담기",
"totalPrice": "총 가격"
},
"footer": {
"catchphrase": "프리미엄 향기로 공간을 업그레이드하세요.",
"explore": "탐색",
"support": "지원",
"followUs": "팔로우",
"contactUs": "연락처",
"rights": "모든 권리 보유."
},
"common": {
"home": "홈",
"shop": "쇼핑",
"viewAll": "모두 보기 →",
"backToShop": "쇼핑으로 돌아가기"
},
"product": {
"notFound": "상품을 찾을 수 없습니다",
"premiumCollection": "프리미엄 컬렉션",
"selectSize": "사이즈 선택",
"details": "상품 상세 정보",
"scent": "향기",
"category": "카테고리",
"relatedProducts": "관련 상품",
"moreRelated": "더 많은 {{category}}",
"reviews": "리뷰",
"viewDetails": "상세 보기 →"
}
}
@@ -0,0 +1,61 @@
{
"nav": {
"home": "Trang chủ",
"shop": "Cửa hàng",
"collections": "Bộ sưu tập",
"scentGuide": "Hướng dẫn mùi hương",
"about": "Giới thiệu",
"menu": "Menu",
"faq": "Câu hỏi thường gặp",
"contact": "Liên hệ",
"shopNow": "Mua ngay",
"findMyScent": "Tìm mùi hương của bạn ✨",
"promotions": "Khuyến mãi",
"profile": "Hồ sơ của tôi",
"search": {
"title": "Tìm kiếm",
"placeholder": "Tìm mùi hương...",
"quickLinks": "Liên kết nhanh",
"allProducts": "Tất cả sản phẩm"
},
"trackOrder": "Theo dõi đơn hàng",
"delivery": "Giao hàng & Hoàn trả"
},
"cart": {
"title": "Giỏ hàng",
"items": "{{count}} sản phẩm",
"empty": "Giỏ hàng của bạn đang trống.",
"continueShopping": "Tiếp tục mua sắm",
"subtotal": "Tổng phụ",
"shippingNote": "Phí vận chuyển và thuế được tính lúc thanh toán.",
"proceed": "Tiến hành thanh toán",
"addToCart": "Thêm vào giỏ",
"totalPrice": "Tổng giá"
},
"footer": {
"catchphrase": "Nâng tầm không gian với hương thơm cao cấp.",
"explore": "Khám phá",
"support": "Hỗ trợ",
"followUs": "Theo dõi chúng tôi",
"contactUs": "Liên hệ",
"rights": "Bảo lưu mọi quyền."
},
"common": {
"home": "Trang chủ",
"shop": "Cửa hàng",
"viewAll": "Xem tất cả →",
"backToShop": "Quay lại cửa hàng"
},
"product": {
"notFound": "Không tìm thấy sản phẩm",
"premiumCollection": "Bộ sưu tập Cao cấp",
"selectSize": "Chọn kích thước",
"details": "Chi tiết sản phẩm",
"scent": "Mùi hương",
"category": "Danh mục",
"relatedProducts": "Sản phẩm liên quan",
"moreRelated": "Thêm {{category}}",
"reviews": "Đánh giá",
"viewDetails": "Xem chi tiết →"
}
}
@@ -0,0 +1,61 @@
{
"nav": {
"home": "首页",
"shop": "商店",
"collections": "系列",
"scentGuide": "香气指南",
"about": "关于我们",
"menu": "菜单",
"faq": "常见问题",
"contact": "联系我们",
"shopNow": "立即购买",
"findMyScent": "寻找我的香气 ✨",
"promotions": "促销",
"profile": "我的主页",
"search": {
"title": "搜索",
"placeholder": "搜索香气...",
"quickLinks": "快速链接",
"allProducts": "所有商品"
},
"trackOrder": "追踪订单",
"delivery": "配送与退货"
},
"cart": {
"title": "购物车",
"items": "{{count}} 件",
"empty": "您的购物车是空的。",
"continueShopping": "继续购物",
"subtotal": "小计",
"shippingNote": "运费和税费将在结账时计算。",
"proceed": "去结账",
"addToCart": "加入购物车",
"totalPrice": "总价"
},
"footer": {
"catchphrase": "用高级香氛提升您的空间。",
"explore": "探索",
"support": "支持",
"followUs": "关注我们",
"contactUs": "联系我们",
"rights": "版权所有。"
},
"common": {
"home": "首页",
"shop": "商店",
"viewAll": "查看全部 →",
"backToShop": "返回商店"
},
"product": {
"notFound": "未找到商品",
"premiumCollection": "高级系列",
"selectSize": "选择尺寸",
"details": "商品详情",
"scent": "香气",
"category": "类别",
"relatedProducts": "相关商品",
"moreRelated": "更多 {{category}}",
"reviews": "评价",
"viewDetails": "查看详情 →"
}
}