fix page
This commit is contained in:
1 parent
a7091858e0
commit
ba5c0aba50
9 files changed
+719
-431
No files matched your search
@@ -1,26 +1,123 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { API_BASE, type Menu } from '@homaroma/types'
|
import { API_BASE, type Menu } from '@homaroma/types'
|
||||||
import { Save, Loader2, Plus, Trash2, GripVertical } from 'lucide-react'
|
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
|
||||||
|
const childrenKey = type === 'root' ? 'columns' : 'links';
|
||||||
|
const labelKey = type === 'column' ? 'title' : 'label';
|
||||||
|
const hasUrl = type !== 'column';
|
||||||
|
|
||||||
|
const children = node[childrenKey] || [];
|
||||||
|
|
||||||
|
const handleAddChild = () => {
|
||||||
|
const newChildType = type === 'root' ? 'column' : 'link';
|
||||||
|
const newChild = newChildType === 'column'
|
||||||
|
? { title: { en: 'New Column' }, links: [] }
|
||||||
|
: { label: { en: 'New Link' }, url: '/', links: [] };
|
||||||
|
onChange({ ...node, [childrenKey]: [...children, newChild] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateChild = (idx: number, newChild: any) => {
|
||||||
|
const newChildren = [...children];
|
||||||
|
newChildren[idx] = newChild;
|
||||||
|
onChange({ ...node, [childrenKey]: newChildren });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveChild = (idx: number) => {
|
||||||
|
onChange({ ...node, [childrenKey]: children.filter((_: any, i: number) => i !== idx) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||||
|
const nodeLabel = node[labelKey] || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border border-white/10 p-4 rounded-xl mt-4 ${depth > 0 ? 'ml-6 border-l-cyan-500/30' : ''}`}>
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-500">{labelKey} Translations</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{languages.map(lang => (
|
||||||
|
<div key={lang} className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase w-4">{lang}</span>
|
||||||
|
<input
|
||||||
|
className="admin-input w-full text-xs py-1 px-2"
|
||||||
|
value={nodeLabel[lang] || ''}
|
||||||
|
placeholder={`e.g. in ${lang}`}
|
||||||
|
onChange={e => onChange({ ...node, [labelKey]: { ...nodeLabel, [lang]: e.target.value } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasUrl && (
|
||||||
|
<div className="w-48 space-y-1">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-500">url</label>
|
||||||
|
<input
|
||||||
|
className="admin-input w-full"
|
||||||
|
value={node.url || ''}
|
||||||
|
onChange={e => onChange({ ...node, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button type="button" onClick={handleAddChild} className="px-3 py-2 text-xs flex gap-1 items-center bg-white/5 text-cyan-glow hover:bg-white/10 rounded-lg">
|
||||||
|
<Plus className="w-3 h-3" /> Add {type === 'root' ? 'Column' : 'Link'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onRemove} className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg flex items-center gap-1 justify-center">
|
||||||
|
<Trash2 className="w-4 h-4" /> <span className="text-xs">Remove</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{children.map((child: any, i: number) => (
|
||||||
|
<TreeNodeEditor
|
||||||
|
key={i}
|
||||||
|
node={child}
|
||||||
|
type={type === 'root' ? 'column' : 'link'}
|
||||||
|
depth={depth + 1}
|
||||||
|
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
|
||||||
|
onRemove={() => handleRemoveChild(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function MenuManagerPage() {
|
export default function MenuManagerPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [menus, setMenus] = useState<Menu[]>([])
|
const [menus, setMenus] = useState<Menu[]>([])
|
||||||
|
const [pages, setPages] = useState<any[]>([])
|
||||||
|
const [copiedSlug, setCopiedSlug] = useState<string | null>(null)
|
||||||
|
|
||||||
// We'll manage multiple menus, e.g., 'main' and 'footer-explore'
|
// We'll manage multiple menus, e.g., 'main' and 'footer-explore'
|
||||||
const [selectedMenuName, setSelectedMenuName] = useState('main')
|
const [selectedMenuName, setSelectedMenuName] = useState('main')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/menus`)
|
Promise.all([
|
||||||
.then(r => r.json())
|
fetch(`${API_BASE}/menus`).then(r => r.json()),
|
||||||
.then((data: Menu[]) => {
|
fetch(`${API_BASE}/pages`).then(r => r.json())
|
||||||
setMenus(data)
|
])
|
||||||
|
.then(([menusData, pagesData]) => {
|
||||||
|
setMenus(menusData)
|
||||||
|
setPages(pagesData)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const currentMenu = menus.find(m => m.name === selectedMenuName) || { id: 0, name: selectedMenuName, items: [] }
|
const currentMenu = menus.find(m => m.name === selectedMenuName) || { id: 0, name: selectedMenuName, items: [], payload: [] }
|
||||||
|
|
||||||
|
const handleUpdatePayload = (newPayload: any[]) => {
|
||||||
|
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, payload: newPayload } : m))
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const newItems = [...currentMenu.items, { id: Date.now(), label: 'New Link', url: '/', order: currentMenu.items.length }]
|
const newItems = [...currentMenu.items, { id: Date.now(), label: 'New Link', url: '/', order: currentMenu.items.length }]
|
||||||
@@ -47,10 +144,14 @@ export default function MenuManagerPage() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const body = selectedMenuName === 'main'
|
||||||
|
? { items: currentMenu.items || [], payload: currentMenu.payload || [] }
|
||||||
|
: { items: currentMenu.items || [] }
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/menus/${selectedMenuName}`, {
|
const res = await fetch(`${API_BASE}/menus/${selectedMenuName}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items: currentMenu.items })
|
body: JSON.stringify(body)
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert('Menu saved successfully!')
|
alert('Menu saved successfully!')
|
||||||
@@ -74,8 +175,9 @@ export default function MenuManagerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl space-y-8 animate-fade-in">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-7xl animate-fade-in">
|
||||||
<div className="space-y-1">
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold">Menu Manager</h1>
|
<h1 className="text-3xl font-bold">Menu Manager</h1>
|
||||||
<p className="text-slate-500 text-sm">Manage navigation links for the main navbar and footer.</p>
|
<p className="text-slate-500 text-sm">Manage navigation links for the main navbar and footer.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,15 +202,56 @@ export default function MenuManagerPage() {
|
|||||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
||||||
Menu Items ({selectedMenuName})
|
Menu Items ({selectedMenuName})
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
{selectedMenuName !== 'main' && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleAddItem}
|
type="button"
|
||||||
className="flex items-center gap-2 text-xs font-bold text-cyan-glow hover:underline"
|
onClick={handleAddItem}
|
||||||
>
|
className="flex items-center gap-2 text-xs font-bold text-cyan-glow hover:underline"
|
||||||
<Plus className="w-4 h-4" /> Add Item
|
>
|
||||||
</button>
|
<Plus className="w-4 h-4" /> Add Item
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedMenuName === 'main' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-slate-500 text-sm">Mega Menu payload builder</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newPayload = [...(currentMenu.payload || []), { id: Date.now().toString(), label: { en: 'New Menu' }, url: '/', columns: [] }]
|
||||||
|
handleUpdatePayload(newPayload)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 text-xs font-bold text-cyan-glow hover:underline"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Add Root Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(currentMenu.payload || []).length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic">No items in payload.</p>
|
||||||
|
) : (
|
||||||
|
(currentMenu.payload || []).map((node: any, idx: number) => (
|
||||||
|
<TreeNodeEditor
|
||||||
|
key={node.id || idx}
|
||||||
|
node={node}
|
||||||
|
type="root"
|
||||||
|
depth={0}
|
||||||
|
onChange={(newNode: any) => {
|
||||||
|
const p = [...(currentMenu.payload || [])];
|
||||||
|
p[idx] = newNode;
|
||||||
|
handleUpdatePayload(p);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
const p = (currentMenu.payload || []).filter((_: any, i: number) => i !== idx);
|
||||||
|
handleUpdatePayload(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{currentMenu.items.length === 0 ? (
|
{currentMenu.items.length === 0 ? (
|
||||||
<p className="text-slate-500 text-sm italic">No items in this menu.</p>
|
<p className="text-slate-500 text-sm italic">No items in this menu.</p>
|
||||||
@@ -185,6 +328,7 @@ export default function MenuManagerPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -196,6 +340,60 @@ export default function MenuManagerPage() {
|
|||||||
Save Menu
|
Save Menu
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar for Dynamic Pages */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="admin-card p-6 space-y-4">
|
||||||
|
<div className="border-b border-white/5 pb-4">
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
||||||
|
Dynamic Pages
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-xs mt-1">Click to copy URL path</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic">No dynamic pages found.</p>
|
||||||
|
) : (
|
||||||
|
pages.map((page) => {
|
||||||
|
const titleStr = typeof page.title === 'string' ? page.title : (page.title?.en || 'Untitled')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={page.id}
|
||||||
|
onClick={() => {
|
||||||
|
const url = `/${page.slug}`
|
||||||
|
navigator.clipboard.writeText(url)
|
||||||
|
setCopiedSlug(page.slug)
|
||||||
|
setTimeout(() => setCopiedSlug(null), 3000)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-colors group text-left"
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden pr-2">
|
||||||
|
<p className="text-sm font-bold text-slate-200 truncate">{titleStr}</p>
|
||||||
|
<p className="text-xs text-slate-500 truncate">/{page.slug}</p>
|
||||||
|
</div>
|
||||||
|
{copiedSlug === page.slug ? (
|
||||||
|
<Check className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-slate-600 group-hover:text-cyan-glow shrink-0 transition-colors" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{copiedSlug && (
|
||||||
|
<div className="fixed bottom-8 right-8 bg-emerald-500 text-night-900 px-6 py-3 rounded-xl font-bold shadow-lg flex items-center gap-2 animate-fade-in z-50">
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
Copied URL: /{copiedSlug}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Menu" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "payload" JSONB;
|
||||||
@@ -589,6 +589,8 @@ model NewsletterSubscription {
|
|||||||
model Menu {
|
model Menu {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique // e.g., "header", "footer"
|
name String @unique // e.g., "header", "footer"
|
||||||
|
payload Json?
|
||||||
|
isActive Boolean @default(true)
|
||||||
items MenuItem[]
|
items MenuItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
return { name, items: [] };
|
return { name, items: [], payload: null, isActive: false };
|
||||||
}
|
}
|
||||||
return menu;
|
return menu;
|
||||||
})
|
})
|
||||||
@@ -59,11 +59,21 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
|||||||
})
|
})
|
||||||
.put("/:name", async ({ params: { name }, body, set }) => {
|
.put("/:name", async ({ params: { name }, body, set }) => {
|
||||||
try {
|
try {
|
||||||
const { items } = body as { items: { id?: number; label: string; url: string; order: number; translations?: { langCode: string; label: string }[] }[] };
|
const { items, payload, isActive } = body as {
|
||||||
|
payload?: any;
|
||||||
|
isActive?: boolean;
|
||||||
|
items: { id?: number; label: string; url: string; order: number; translations?: { langCode: string; label: string }[] }[]
|
||||||
|
};
|
||||||
let menu = await prisma.menu.findUnique({ where: { name } });
|
let menu = await prisma.menu.findUnique({ where: { name } });
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (payload !== undefined) updateData.payload = payload;
|
||||||
|
if (isActive !== undefined) updateData.isActive = isActive;
|
||||||
|
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
menu = await prisma.menu.create({ data: { name } });
|
menu = await prisma.menu.create({ data: { name, ...updateData } });
|
||||||
|
} else if (Object.keys(updateData).length > 0) {
|
||||||
|
menu = await prisma.menu.update({ where: { name }, data: updateData });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx: any) => {
|
await prisma.$transaction(async (tx: any) => {
|
||||||
@@ -121,6 +131,8 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
|
payload: t.Optional(t.Any()),
|
||||||
|
isActive: t.Optional(t.Boolean()),
|
||||||
items: t.Array(t.Object({
|
items: t.Array(t.Object({
|
||||||
id: t.Optional(t.Number()),
|
id: t.Optional(t.Number()),
|
||||||
label: t.String(),
|
label: t.String(),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { MegaMenuPayload } from '../store/menuStore';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface DesktopMegaMenuProps {
|
||||||
|
items: MegaMenuPayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLocalizedText = (textObj: Record<string, string> | string | undefined, lang: string) => {
|
||||||
|
if (typeof textObj === 'string') return textObj;
|
||||||
|
if (!textObj) return '';
|
||||||
|
return textObj[lang] || textObj['en'] || Object.values(textObj)[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language ? i18n.language.substring(0, 2) : 'en';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="relative group"
|
||||||
|
onMouseEnter={() => setOpenId(item.id)}
|
||||||
|
onMouseLeave={() => setOpenId(null)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={item.url || '#'}
|
||||||
|
className="flex items-center gap-1 text-spa-stone hover:text-spa-forest transition-colors py-2"
|
||||||
|
>
|
||||||
|
{getLocalizedText(item.label, lang)}
|
||||||
|
{item.columns && item.columns.length > 0 && (
|
||||||
|
<svg className="w-3 h-3 opacity-50 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" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{item.columns && item.columns.length > 0 && openId === item.id && (
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 pt-4 w-auto min-w-[320px] opacity-100 pointer-events-auto transition-all duration-300 z-50">
|
||||||
|
<div className="bg-white/95 backdrop-blur-xl border border-black/5 rounded-xl overflow-hidden shadow-2xl p-5 flex gap-8">
|
||||||
|
{item.columns.map((col, idx) => (
|
||||||
|
<div key={idx} className="flex-1 min-w-[140px]">
|
||||||
|
<h4 className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold mb-3">
|
||||||
|
{getLocalizedText(col.title, lang)}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{col.links.map((link, lIdx) => (
|
||||||
|
<li key={lIdx}>
|
||||||
|
<Link
|
||||||
|
to={link.url || '#'}
|
||||||
|
className="text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest hover:bg-spa-sage px-3 py-2 rounded-lg block transition-colors"
|
||||||
|
>
|
||||||
|
{getLocalizedText(link.label, lang)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { MegaMenuPayload, MenuColumn, MenuLink } from '../store/menuStore';
|
||||||
|
import { X, ChevronRight, ChevronLeft, Globe } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { loginLiff, logoutLiff } from '../lib/liff';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface MobileDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
items: MegaMenuPayload[];
|
||||||
|
onOpenLang: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLocalizedText = (textObj: Record<string, string> | string | undefined, lang: string) => {
|
||||||
|
if (typeof textObj === 'string') return textObj;
|
||||||
|
if (!textObj) return '';
|
||||||
|
return textObj[lang] || textObj['en'] || Object.values(textObj)[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
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> };
|
||||||
|
|
||||||
|
export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawerProps) {
|
||||||
|
const { customer } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language ? i18n.language.substring(0, 2) : 'en';
|
||||||
|
|
||||||
|
const [stack, setStack] = useState<MenuNode[]>([{ type: 'root', data: items, title: 'Menu' }]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setStack([{ type: 'root', data: items, title: 'Menu' }]);
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
if (stack.length === 1 && stack[0].data !== items) {
|
||||||
|
setStack([{ type: 'root', data: items, title: 'Menu' }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, items]);
|
||||||
|
|
||||||
|
const handleLinkClick = (url: string) => {
|
||||||
|
navigate(url);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushView = (node: MenuNode) => {
|
||||||
|
setStack((prev) => [...prev, node]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const popView = () => {
|
||||||
|
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = (view: MenuNode) => {
|
||||||
|
if (view.type === 'root') {
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-2 space-y-1">
|
||||||
|
{view.data.map((item) => (
|
||||||
|
<div key={item.id} className="border-b border-spa-stone/10 last:border-0">
|
||||||
|
{item.columns && item.columns.length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => pushView({ type: 'mega', data: item.columns!, title: item.label })}
|
||||||
|
className="w-full flex items-center justify-between py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase group"
|
||||||
|
>
|
||||||
|
<span>{getLocalizedText(item.label, lang)}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleLinkClick(item.url || '#')}
|
||||||
|
className="w-full text-left py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase"
|
||||||
|
>
|
||||||
|
{getLocalizedText(item.label, lang)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{customer && (
|
||||||
|
<div className="border-b border-spa-stone/10">
|
||||||
|
<button
|
||||||
|
onClick={() => handleLinkClick('/profile')}
|
||||||
|
className="w-full text-left py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase"
|
||||||
|
>
|
||||||
|
👤 My Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.type === 'mega') {
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{view.data.map((col, idx) => (
|
||||||
|
<div key={idx} className="mb-8 last:mb-0">
|
||||||
|
<h4 className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-bold mb-4">
|
||||||
|
{getLocalizedText(col.title, lang)}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{col.links.map((link, lIdx) => (
|
||||||
|
<div key={lIdx}>
|
||||||
|
{link.links && link.links.length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
||||||
|
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest group"
|
||||||
|
>
|
||||||
|
<span>{getLocalizedText(link.label, lang)}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleLinkClick(link.url || '#')}
|
||||||
|
className="w-full text-left py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest"
|
||||||
|
>
|
||||||
|
{getLocalizedText(link.label, lang)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.type === 'links') {
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4 space-y-2">
|
||||||
|
{view.data.map((link, lIdx) => (
|
||||||
|
<div key={lIdx}>
|
||||||
|
{link.links && link.links.length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
||||||
|
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest group"
|
||||||
|
>
|
||||||
|
<span>{getLocalizedText(link.label, lang)}</span>
|
||||||
|
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleLinkClick(link.url || '#')}
|
||||||
|
className="w-full text-left py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest"
|
||||||
|
>
|
||||||
|
{getLocalizedText(link.label, lang)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-[60] md:hidden transition-all duration-300 ${isOpen ? 'visible' : 'invisible'}`}>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 right-0 h-full w-80 max-w-[85vw] bg-white shadow-2xl flex flex-col transition-transform duration-300 ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
||||||
|
>
|
||||||
|
{/* Static Top Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-spa-stone/10 bg-white z-10 shrink-0">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest text-spa-forest">Menu</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={onOpenLang} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-spa-forest border border-spa-forest/30 uppercase bg-spa-sage flex items-center gap-1 shrink-0">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{i18n.language.substring(0, 2).toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sliding Views Container */}
|
||||||
|
<div className="flex-1 relative 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Back Button for nested views */}
|
||||||
|
{index > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={popView}
|
||||||
|
className="flex items-center px-6 py-4 text-xs font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/5 hover:bg-spa-sage transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to {getLocalizedText(stack[index - 1].title, lang)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional View Title Header for nested views to emphasize context */}
|
||||||
|
{index > 0 && (
|
||||||
|
<div className="px-6 py-5 shrink-0">
|
||||||
|
<h3 className="text-lg font-bold uppercase tracking-widest text-spa-forest">{getLocalizedText(view.title, lang)}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto pb-6">
|
||||||
|
{renderView(view)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Static Bottom Footer */}
|
||||||
|
<div className="px-6 pb-8 pt-6 border-t border-spa-stone/10 bg-white z-10 shrink-0 space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleLinkClick('/shop')}
|
||||||
|
className="block w-full py-3.5 bg-spa-forest text-white font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors duration-300 shadow-sm"
|
||||||
|
>
|
||||||
|
Shop Now
|
||||||
|
</button>
|
||||||
|
{customer ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { logoutLiff(); onClose(); }}
|
||||||
|
className="block w-full py-3.5 text-center rounded-xl text-xs font-bold uppercase tracking-widest text-rose-400 hover:bg-rose-50 transition-colors border border-rose-500/20"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { loginLiff(); onClose(); }}
|
||||||
|
className="block w-full py-3.5 text-center rounded-xl text-xs font-bold uppercase tracking-widest text-[#00B900] hover:bg-[#00B900]/10 transition-colors border border-[#00B900]/50"
|
||||||
|
>
|
||||||
|
Login with LINE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+101
-412
@@ -1,27 +1,31 @@
|
|||||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { API_BASE, type Category, type MenuItem } from '../types'
|
import { type Category } from '../types'
|
||||||
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart, Globe, Check } from 'lucide-react'
|
import { ShoppingBag, Menu as MenuIcon, X, Heart, Globe, Check } from 'lucide-react'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import { useWishlistStore } from '../store/wishlistStore'
|
import { useWishlistStore } from '../store/wishlistStore'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { logoutLiff, loginLiff } from '../lib/liff'
|
import { logoutLiff, loginLiff } from '../lib/liff'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||||
|
import { DesktopMegaMenu } from './DesktopMegaMenu'
|
||||||
|
import { MobileDrawer } from './MobileDrawer'
|
||||||
|
import { useMenuStore } from '../store/menuStore'
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
categories?: Category[]
|
categories?: Category[]
|
||||||
topOffsetClassName?: string
|
topOffsetClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }: NavbarProps) {
|
export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||||
|
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [localOrdersCount, setLocalOrdersCount] = useState(0)
|
|
||||||
const [hasAnnouncement, setHasAnnouncement] = useState(false)
|
const [hasAnnouncement, setHasAnnouncement] = useState(false)
|
||||||
const [menuItems, setMenuItems] = useState<MenuItem[]>([])
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { toggleCart, getCartCount } = useCartStore()
|
const { toggleCart, getCartCount } = useCartStore()
|
||||||
@@ -29,6 +33,8 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
const wishlistCount = useWishlistStore(state => state.items.length)
|
const wishlistCount = useWishlistStore(state => state.items.length)
|
||||||
const { customer } = useAuthStore()
|
const { customer } = useAuthStore()
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
|
const { megaMenu, fetchMegaMenu } = useMenuStore()
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'th', label: 'ไทย', short: 'TH' },
|
{ code: 'th', label: 'ไทย', short: 'TH' },
|
||||||
@@ -45,35 +51,13 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
setLangSheetOpen(false)
|
setLangSheetOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const translateMenuLabel = (item: MenuItem) => {
|
|
||||||
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
|
||||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
|
||||||
const trans = item.translations.find((t: any) => t.langCode === lang)
|
|
||||||
if (trans && trans.label) {
|
|
||||||
return trans.label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'Home': 'nav.home',
|
|
||||||
'Shop': 'nav.shop',
|
|
||||||
'Scent Guide': 'nav.scentGuide',
|
|
||||||
'About Us': 'nav.about',
|
|
||||||
'FAQ': 'nav.faq',
|
|
||||||
'Contact': 'nav.contact',
|
|
||||||
'Contact Us': 'nav.contact',
|
|
||||||
'Track Order': 'nav.trackOrder',
|
|
||||||
'Delivery & Returns': 'nav.delivery'
|
|
||||||
}
|
|
||||||
return map[item.label] ? t(map[item.label]) : item.label
|
|
||||||
}
|
|
||||||
// Detect scroll and announcement bar presence
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchMegaMenu()
|
||||||
|
|
||||||
const checkAnnouncement = () => {
|
const checkAnnouncement = () => {
|
||||||
const el = document.querySelector('.announcement-bar')
|
const el = document.querySelector('.announcement-bar')
|
||||||
setHasAnnouncement(!!el)
|
setHasAnnouncement(!!el)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAnnouncement()
|
checkAnnouncement()
|
||||||
const timer = setTimeout(checkAnnouncement, 300)
|
const timer = setTimeout(checkAnnouncement, 300)
|
||||||
|
|
||||||
@@ -83,34 +67,13 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
}
|
}
|
||||||
window.addEventListener('scroll', onScroll, { passive: true })
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
|
||||||
// Check local orders periodically (or just on mount)
|
|
||||||
const stored = localStorage.getItem('homaroma_local_orders')
|
|
||||||
if (stored) {
|
|
||||||
try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch dynamic menu
|
|
||||||
fetch(`${API_BASE}/menus/main`)
|
|
||||||
.then(res => {
|
|
||||||
if (!res.ok) throw new Error('Menu not found')
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.items) {
|
|
||||||
setMenuItems(data.items.sort((a: any, b: any) => a.order - b.order))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', onScroll)
|
window.removeEventListener('scroll', onScroll)
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [fetchMegaMenu])
|
||||||
|
|
||||||
// Close mobile menu on route change
|
|
||||||
useEffect(() => { setMobileOpen(false) }, [navigate])
|
useEffect(() => { setMobileOpen(false) }, [navigate])
|
||||||
// Prevent body scroll when overlays open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
|
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
|
||||||
return () => { document.body.style.overflow = '' }
|
return () => { document.body.style.overflow = '' }
|
||||||
@@ -126,9 +89,6 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
|
||||||
isActive ? 'text-spa-forest font-semibold' : 'text-spa-stone hover:text-spa-forest transition-colors'
|
|
||||||
|
|
||||||
const computedOffset = topOffsetClassName !== 'top-0'
|
const computedOffset = topOffsetClassName !== 'top-0'
|
||||||
? topOffsetClassName
|
? topOffsetClassName
|
||||||
: (hasAnnouncement ? 'top-[32px] md:top-[40px]' : 'top-0')
|
: (hasAnnouncement ? 'top-[32px] md:top-[40px]' : 'top-0')
|
||||||
@@ -137,96 +97,33 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── NAVBAR BAR ── */}
|
|
||||||
<nav
|
<nav
|
||||||
className={`fixed ${finalTopClassName} w-full z-50 transition-all duration-300 px-4 md:px-6 py-3.5 md:py-4 flex justify-between items-center bg-white/80 backdrop-blur-xl border-b border-black/5 ${
|
className={`fixed ${finalTopClassName} w-full z-50 transition-all duration-300 px-4 md:px-6 py-3.5 md:py-4 flex justify-between items-center bg-white/80 backdrop-blur-xl border-b border-black/5 ${
|
||||||
scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none'
|
scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
|
||||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
||||||
HomAroma <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
|
HomAroma <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* ── DESKTOP NAV ── */}
|
{isDesktop && <DesktopMegaMenu items={megaMenu} />}
|
||||||
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
|
||||||
{menuItems.length > 0 ? (
|
|
||||||
menuItems.map(item => (
|
|
||||||
<NavLink key={item.id} to={item.url} end={item.url === '/'} className={navLinkClass}>
|
|
||||||
{translateMenuLabel(item)}
|
|
||||||
</NavLink>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
|
|
||||||
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
|
|
||||||
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collections dropdown */}
|
{/* ── ACTIONS ── */}
|
||||||
{categories.length > 0 && (
|
<div className="flex items-center gap-1.5 md:gap-3">
|
||||||
<div className="relative group">
|
|
||||||
<button className="text-spa-stone hover:text-spa-forest transition-colors flex items-center gap-1 uppercase text-sm font-medium tracking-wide">
|
|
||||||
{t('nav.collections')}
|
|
||||||
<svg className="w-3 h-3 opacity-50 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" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 pt-4 w-52 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
|
||||||
<div className="bg-white/95 backdrop-blur-xl border border-black/5 rounded-xl overflow-hidden shadow-2xl p-2 space-y-0.5">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Link
|
|
||||||
key={cat.id}
|
|
||||||
to={`/category/${cat.id}`}
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 text-xs uppercase tracking-widest text-spa-stone rounded-lg hover:bg-spa-sage hover:text-spa-forest transition-colors"
|
|
||||||
>
|
|
||||||
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[i18n.language] || (cat.name as any)?.en || ''}
|
|
||||||
<svg className="w-3 h-3 opacity-30" 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{menuItems.length === 0 && (
|
|
||||||
<>
|
|
||||||
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
|
|
||||||
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── DESKTOP RIGHT ACTIONS ── */}
|
|
||||||
<div className="hidden md:flex items-center gap-3">
|
|
||||||
<Link
|
|
||||||
to="/track"
|
|
||||||
className="relative p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
|
||||||
aria-label="Track Orders"
|
|
||||||
title="Track Orders"
|
|
||||||
>
|
|
||||||
<MapPin className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
|
|
||||||
{localOrdersCount > 0 && (
|
|
||||||
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||||
className="p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
className="p-1.5 md:p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 group-hover:text-spa-forest transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4 md:w-4 md:h-4 group-hover:text-spa-forest 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/wishlist"
|
to="/wishlist"
|
||||||
className="relative p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="relative p-1.5 md:p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
||||||
aria-label="Wishlist"
|
aria-label="Wishlist"
|
||||||
>
|
>
|
||||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||||
@@ -238,8 +135,8 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCart(true)}
|
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||||
className="relative p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
className="relative p-1.5 md:p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
|
||||||
aria-label="Cart"
|
aria-label="Cart"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
|
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
|
||||||
@@ -249,283 +146,95 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
|
||||||
to="/shop"
|
|
||||||
className="px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-spa-forest text-white hover:bg-spa-stone transition-all duration-300 shadow-sm"
|
|
||||||
>
|
|
||||||
{t('nav.shopNow')}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="relative group flex items-center">
|
{isDesktop && (
|
||||||
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-spa-forest border border-spa-forest/30 hover:bg-spa-sage bg-white flex items-center gap-1.5 uppercase tracking-wider">
|
<>
|
||||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
<div className="relative group flex items-center">
|
||||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-spa-forest border border-spa-forest/30 hover:bg-spa-sage bg-white flex items-center gap-1.5 uppercase tracking-wider">
|
||||||
</button>
|
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
<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">
|
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||||
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
</button>
|
||||||
{LANGUAGES.map(lang => {
|
<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">
|
||||||
const isActive = i18n.language.startsWith(lang.code)
|
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||||
return (
|
{LANGUAGES.map(lang => {
|
||||||
<button
|
const isActive = i18n.language.startsWith(lang.code)
|
||||||
key={lang.code}
|
return (
|
||||||
onClick={() => changeLanguage(lang.code)}
|
<button
|
||||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-spa-forest bg-spa-sage/60 font-bold' : 'text-spa-stone hover:bg-spa-light hover:text-spa-forest'}`}
|
key={lang.code}
|
||||||
>
|
onClick={() => changeLanguage(lang.code)}
|
||||||
<span>{lang.label}</span>
|
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-spa-forest bg-spa-sage/60 font-bold' : 'text-spa-stone hover:bg-spa-light hover:text-spa-forest'}`}
|
||||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
>
|
||||||
</button>
|
<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">
|
|
||||||
<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-spa-forest/30 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-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
|
|
||||||
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
|
|
||||||
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
|
||||||
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
to="/profile"
|
|
||||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest hover:bg-spa-light 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>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
{customer ? (
|
||||||
<button
|
<div className="relative group flex items-center ml-1">
|
||||||
onClick={loginLiff}
|
<Link to="/profile">
|
||||||
className="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"
|
<img
|
||||||
>
|
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||||
{t('nav.login', 'LINE Login')}
|
alt="Profile"
|
||||||
</button>
|
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 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-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||||
|
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
|
||||||
|
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||||
|
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
className="block px-3 py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest hover:bg-spa-light 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>
|
|
||||||
|
{!isDesktop && (
|
||||||
{/* ── MOBILE RIGHT ACTIONS ── */}
|
|
||||||
<div className="flex md:hidden items-center gap-1.5">
|
|
||||||
{customer ? (
|
|
||||||
<div className="relative">
|
|
||||||
<Link to="/profile">
|
|
||||||
<img
|
|
||||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
|
||||||
alt="Profile"
|
|
||||||
className="w-7 h-7 rounded-full border-2 border-spa-forest/30 object-cover cursor-pointer"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
onClick={loginLiff}
|
onClick={() => setMobileOpen(prev => !prev)}
|
||||||
className="p-1.5 rounded-full text-[#00B900] hover:bg-[#00B900]/10 transition-all border border-[#00B900]/30 bg-white"
|
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
||||||
aria-label="LINE Login"
|
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||||
<path d="M24 10.304c0-5.369-5.383-9.738-12-9.738-6.616 0-12 4.369-12 9.738 0 4.814 3.932 8.946 9.539 9.615.375.056 1.155.176 1.321.603.149.387.049.991.023 1.258-.035.342-.165 1.011 1.05 1.503 1.216.492 4.417-2.602 6.012-4.423C21.841 16.208 24 13.524 24 10.304z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/track"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="relative p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
|
||||||
aria-label="Track Orders"
|
|
||||||
>
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
{localOrdersCount > 0 && (
|
|
||||||
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchOpen(true); setMobileOpen(false) }}
|
|
||||||
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-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>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/wishlist"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="relative p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
|
||||||
aria-label="Wishlist"
|
|
||||||
>
|
|
||||||
<Heart className="w-4 h-4" />
|
|
||||||
{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="relative p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
|
||||||
aria-label="Cart"
|
|
||||||
>
|
|
||||||
<ShoppingBag className="w-4 h-4" />
|
|
||||||
{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>
|
|
||||||
{/* Hamburger */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileOpen(prev => !prev)}
|
|
||||||
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
|
||||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
|
||||||
>
|
|
||||||
{mobileOpen ? (
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<MenuIcon className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── MOBILE DRAWER ── */}
|
{!isDesktop && (
|
||||||
<div
|
<MobileDrawer
|
||||||
className={`fixed inset-0 z-[60] md:hidden transition-all duration-300 ${mobileOpen ? 'visible' : 'invisible'}`}
|
isOpen={mobileOpen}
|
||||||
>
|
onClose={() => setMobileOpen(false)}
|
||||||
{/* Backdrop */}
|
items={megaMenu}
|
||||||
<div
|
onOpenLang={() => setLangSheetOpen(true)}
|
||||||
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
{/* Drawer */}
|
)}
|
||||||
<div
|
|
||||||
className={`absolute top-0 right-0 h-full w-72 bg-white/98 backdrop-blur-2xl border-l border-spa-stone/10 flex flex-col transition-transform duration-300 shadow-2xl ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
|
||||||
>
|
|
||||||
{/* Drawer Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-5 border-b border-spa-stone/10">
|
|
||||||
<span className="text-sm font-bold uppercase tracking-widest text-spa-forest">{t('nav.menu')}</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{customer && (
|
|
||||||
<img src={customer.pictureUrl || ''} className="w-6 h-6 rounded-full border-2 border-spa-forest/30" />
|
|
||||||
)}
|
|
||||||
<button onClick={() => setLangSheetOpen(true)} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-spa-forest border border-spa-forest/30 uppercase bg-spa-sage flex items-center gap-1 shrink-0">
|
|
||||||
<Globe className="w-3 h-3" />
|
|
||||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
|
|
||||||
<svg className="w-4 h-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>
|
|
||||||
|
|
||||||
{/* Drawer Nav Links */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
|
||||||
{(menuItems.length > 0 ? [
|
|
||||||
...menuItems.map(item => ({ to: item.url, label: translateMenuLabel(item), end: item.url === '/' })),
|
|
||||||
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
|
||||||
] : [
|
|
||||||
{ to: '/', label: t('nav.home'), end: true },
|
|
||||||
{ to: '/shop', label: t('nav.shop') },
|
|
||||||
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
|
||||||
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
|
|
||||||
{ 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: any) => (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
end={item.end}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${
|
|
||||||
isActive ? 'bg-spa-forest text-white' : 'text-spa-stone hover:bg-spa-sage hover:text-spa-forest'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Collections sub-section */}
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div className="pt-4">
|
|
||||||
<p className="text-[10px] uppercase tracking-widest text-spa-stone/60 px-4 pb-2 font-semibold">{t('nav.collections')}</p>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<Link
|
|
||||||
key={cat.id}
|
|
||||||
to={`/category/${cat.id}`}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 rounded-xl text-sm text-spa-stone hover:text-spa-forest hover:bg-spa-sage transition-all"
|
|
||||||
>
|
|
||||||
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[i18n.language] || (cat.name as any)?.en || ''}
|
|
||||||
<svg className="w-3 h-3 text-spa-stone/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>
|
|
||||||
|
|
||||||
{/* Drawer Footer CTA */}
|
|
||||||
<div className="px-4 pb-8 pt-4 border-t border-spa-stone/10 space-y-3">
|
|
||||||
<Link
|
|
||||||
to="/shop"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="block w-full py-3 bg-spa-forest text-white font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors duration-300"
|
|
||||||
>
|
|
||||||
{t('nav.shopNow')}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/scent-guide"
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="block w-full py-3 text-center rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-spa-sage transition-colors border-2 border-spa-forest text-spa-forest"
|
|
||||||
>
|
|
||||||
{t('nav.findMyScent')}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{customer ? (
|
|
||||||
<button
|
|
||||||
onClick={() => { logoutLiff(); setMobileOpen(false); }}
|
|
||||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-rose-400 hover:bg-rose-500/10 transition-colors border border-rose-500/20 mt-2"
|
|
||||||
>
|
|
||||||
{t('nav.logout', 'Logout')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => { loginLiff(); setMobileOpen(false); }}
|
|
||||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-[#00B900] hover:bg-[#00B900]/10 transition-colors border border-[#00B900]/50 mt-2"
|
|
||||||
>
|
|
||||||
{t('nav.login', 'Login with LINE')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── SEARCH OVERLAY ── */}
|
|
||||||
{searchOpen && (
|
{searchOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] bg-spa-light/97 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
|
className="fixed inset-0 z-[100] bg-spa-light/97 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
|
||||||
@@ -559,30 +268,10 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-spa-stone/60 uppercase tracking-widest mb-3 font-semibold">Quick Links</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{[
|
|
||||||
...categories.map(c => ({ label: typeof c.name === 'string' ? c.name : (c.name as any)?.[i18n.language] || (c.name as any)?.en || '', to: `/category/${c.id}` })),
|
|
||||||
{ label: '✨ Scent Guide', to: '/scent-guide' },
|
|
||||||
{ label: 'All Products', to: '/shop' },
|
|
||||||
].map(item => (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
onClick={() => setSearchOpen(false)}
|
|
||||||
className="px-4 py-1.5 rounded-full text-xs font-medium text-spa-stone hover:bg-spa-forest hover:text-white transition-all border border-spa-stone/20 bg-white"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* ── MOBILE LANGUAGE BOTTOM SHEET ── */}
|
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-[110] md:hidden transition-all duration-300 ${langSheetOpen ? 'visible' : 'invisible'}`}
|
className={`fixed inset-0 z-[110] md:hidden transition-all duration-300 ${langSheetOpen ? 'visible' : 'invisible'}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
if (media.matches !== matches) {
|
||||||
|
setMatches(media.matches);
|
||||||
|
}
|
||||||
|
const listener = () => setMatches(media.matches);
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, [matches, query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
|
||||||
|
export interface MenuLink {
|
||||||
|
label: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
links?: MenuLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuColumn {
|
||||||
|
title: Record<string, string>;
|
||||||
|
links: MenuLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MegaMenuPayload {
|
||||||
|
id: string;
|
||||||
|
label: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
columns?: MenuColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuState {
|
||||||
|
megaMenu: MegaMenuPayload[];
|
||||||
|
isLoading: boolean;
|
||||||
|
fetchMegaMenu: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMenuStore = create<MenuState>((set) => ({
|
||||||
|
megaMenu: [],
|
||||||
|
isLoading: false,
|
||||||
|
fetchMegaMenu: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/menus/main`);
|
||||||
|
const data = await response.json();
|
||||||
|
set({ megaMenu: data.payload || [], isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch mega menu', error);
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
Reference in new issue
Block a user