This commit is contained in:
Your Name committed 2026-07-14 02:26:38 -04:00
1 parent d8cca4fcfe
commit 52a5217d9b
8 files changed
+554 -107

No files matched your search

+8 -3
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AdminLayout from './components/AdminLayout'
import DashboardPage from './pages/DashboardPage'
import ProductsPage from './pages/ProductsPage'
@@ -17,7 +17,9 @@ import PaymentSettingsPage from './pages/PaymentSettingsPage'
import ThemeSettingsPage from './pages/ThemeSettingsPage'
import ContentSettingsPage from './pages/ContentSettingsPage'
import SystemMaintenancePage from './pages/SystemMaintenancePage'
import MenuManagerPage from './pages/MenuManagerPage'
import MainNavigationPage from './pages/menu-manager/MainNavigationPage'
import FooterExplorePage from './pages/menu-manager/FooterExplorePage'
import FooterSupportPage from './pages/menu-manager/FooterSupportPage'
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
import ArticlesPage from './pages/ArticlesPage'
import ArticleEditPage from './pages/ArticleEditPage'
@@ -70,7 +72,10 @@ function App() {
<Route path="/settings/theme" element={<ThemeSettingsPage />} />
<Route path="/settings/content" element={<ContentSettingsPage />} />
<Route path="/settings/system" element={<SystemMaintenancePage />} />
<Route path="/menu-manager" element={<MenuManagerPage />} />
<Route path="/menu-manager" element={<Navigate to="/menu-manager/main-navigation" replace />} />
<Route path="/menu-manager/main-navigation" element={<MainNavigationPage />} />
<Route path="/menu-manager/footer-explore" element={<FooterExplorePage />} />
<Route path="/menu-manager/footer-support" element={<FooterSupportPage />} />
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticleEditPage />} />
+37 -1
View File
@@ -53,11 +53,16 @@ const MAIN_MENU = [
{ icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' },
{ icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' },
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
{ icon: MessageSquare, label: 'Contact Messages', to: '/contacts' },
]
const MENU_MANAGER_MENU = [
{ icon: Layers, label: 'Main Navigation', to: '/menu-manager/main-navigation' },
{ icon: FileText, label: 'Footer Explore', to: '/menu-manager/footer-explore' },
{ icon: MessageSquare, label: 'Footer Support', to: '/menu-manager/footer-support' },
]
const SETTINGS_MENU = [
{ icon: Store, label: 'Store Settings', to: '/settings' },
{ icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' },
@@ -69,7 +74,9 @@ const SETTINGS_MENU = [
export default function Sidebar() {
const navigate = useNavigate()
const location = useLocation()
const isInMenuManager = location.pathname.startsWith('/menu-manager')
const isInSettings = location.pathname.startsWith('/settings')
const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager)
const [settingsOpen, setSettingsOpen] = useState(isInSettings)
const handleLogout = () => {
@@ -104,6 +111,35 @@ export default function Sidebar() {
</NavLink>
))}
{/* Menu Manager Group */}
<div className="pt-1">
<button
onClick={() => setMenuManagerOpen(v => !v)}
className={`sidebar-link sidebar-link-inactive w-full ${isInMenuManager ? 'text-white' : ''}`}
>
<Layers className="w-5 h-5 shrink-0" />
<span className="flex-1 text-left">Menu Manager</span>
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${menuManagerOpen ? 'rotate-180' : ''}`} />
</button>
{menuManagerOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{MENU_MANAGER_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Settings Group */}
<div className="pt-1">
<button
@@ -0,0 +1,11 @@
import MenuEditorPage from './MenuEditorPage'
export default function FooterExplorePage() {
return (
<MenuEditorPage
menuName="footer-explore"
title="Footer Explore"
description="Manage footer links for browsing products, collections, guides, and brand content."
/>
)
}
@@ -0,0 +1,11 @@
import MenuEditorPage from './MenuEditorPage'
export default function FooterSupportPage() {
return (
<MenuEditorPage
menuName="footer-support"
title="Footer Support"
description="Manage footer support links such as policies, contact pages, and customer help."
/>
)
}
@@ -0,0 +1,11 @@
import MenuEditorPage from './MenuEditorPage'
export default function MainNavigationPage() {
return (
<MenuEditorPage
menuName="main"
title="Main Navigation"
description="Manage the desktop and mobile main navigation, including mega menu columns and featured cards."
/>
)
}
@@ -1,8 +1,205 @@
import { useState, useEffect, useRef } from 'react'
import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload } from 'lucide-react'
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react'
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
type MenuEditorPageProps = {
menuName: 'main' | 'footer-explore' | 'footer-support';
title: string;
description: string;
}
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th');
const updateFeatured = (field: string, value: any) => {
onChange({
...featured,
[field]: value
});
};
const updateFeaturedTranslation = (field: string, lang: string, value: string) => {
const current = featured[field] || {};
updateFeatured(field, { ...current, [lang]: value });
};
const contentFields = [
['sectionTitle', 'Section title', 'Featured'],
['badge', 'Badge', 'Recommended'],
['title', 'Card title', 'Product name'],
['subtitle', 'Card subtitle', 'Short description'],
['ctaLabel', 'CTA label', 'View details'],
];
return (
<div className="space-y-4">
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
{[
['content', 'Content'],
['media', 'Media & Link'],
].map(([tab, label]) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab as 'content' | 'media')}
className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
activeTab === tab ? 'bg-cyan-glow text-night-900' : 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{activeTab === 'content' ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{languages.map(lang => (
<button
key={lang}
type="button"
onClick={() => setActiveLang(lang)}
className={`rounded-lg px-3 py-1.5 text-[11px] font-bold uppercase transition-colors ${
activeLang === lang ? 'bg-amber-400/20 text-amber-200' : 'bg-white/5 text-slate-500 hover:bg-white/10 hover:text-slate-300'
}`}
>
{lang}
</button>
))}
</div>
{contentFields.map(([field, label, placeholder]) => {
const value = featured[field]?.[activeLang] || '';
const isLongText = field === 'subtitle';
return (
<div key={field} className="space-y-1.5">
<label className="text-[10px] uppercase font-bold text-slate-500">{label}</label>
{isLongText ? (
<textarea
className="admin-input min-h-20 w-full resize-y text-sm"
value={value}
placeholder={`${placeholder} (${activeLang})`}
onChange={e => updateFeaturedTranslation(field, activeLang, e.target.value)}
/>
) : (
<input
className="admin-input w-full text-sm"
value={value}
placeholder={`${placeholder} (${activeLang})`}
onChange={e => updateFeaturedTranslation(field, activeLang, e.target.value)}
/>
)}
</div>
);
})}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3">
<div>
<p className="text-sm font-bold text-white">Show featured card</p>
<p className="text-xs text-slate-500">Disable this when this root menu should only show link columns.</p>
</div>
<label className="flex items-center gap-2 text-xs font-bold text-slate-300">
<input
type="checkbox"
checked={featured.enabled !== false}
onChange={e => updateFeatured('enabled', e.target.checked)}
className="accent-cyan-glow"
/>
Enabled
</label>
</div>
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
{featured.imageUrl ? (
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
) : (
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
)}
</div>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured URL</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.url || ''}
placeholder="/products/slug-or-page"
onChange={e => updateFeatured('url', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Price / meta text</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.price || ''}
placeholder="e.g. ¥6,600(税込)"
onChange={e => updateFeatured('price', e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
</div>
);
}
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
if (!node) {
return (
<div className="admin-card p-6 space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Featured Card</h2>
<p className="mt-1 text-xs text-slate-500">Add a root menu item first, then select it to edit its featured card.</p>
</div>
</div>
);
}
const rootLabel = node.label?.en || node.label?.th || 'Selected root menu';
const featured = node.featured || {};
return (
<div className="admin-card lg:sticky lg:top-6 max-h-[calc(100vh-3rem)] overflow-y-auto p-6 space-y-5 custom-scrollbar">
<div className="border-b border-white/5 pb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Featured Card</h2>
<p className="mt-1 truncate text-xs text-slate-500">Editing: {rootLabel}</p>
</div>
</div>
</div>
<FeaturedCardEditorFields
featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
/>
</div>
);
}
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
const childrenKey = type === 'root' ? 'columns' : 'links';
const labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column';
@@ -31,9 +228,10 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
onChange({ ...node, [childrenKey]: children.filter((_: any, i: number) => i !== idx) });
};
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
const nodeLabel = node[labelKey] || {};
const imageValue: string = node[imageFieldKey] || '';
const featured = node.featured || {};
const featuredTitle = featured.title?.en || featured.title?.th || featured.sectionTitle?.en || featured.sectionTitle?.th || 'Not configured';
return (
<div className={`border border-white/10 p-4 rounded-xl mt-4 ${depth > 0 ? 'ml-6 border-l-cyan-500/30' : ''}`}>
@@ -76,6 +274,34 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
/>
</div>
</div>
{type === 'root' && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featured.enabled === false ? 'Disabled' : featuredTitle}
</p>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
</div>
)}
</div>
{hasUrl && (
@@ -97,7 +323,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
</button>
</div>
</div>
{children.length > 0 && (
<div className="mt-4 space-y-4">
{children.map((child: any, i: number) => (
@@ -117,15 +343,13 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove }: any) => {
}
export default function MenuManagerPage() {
export default function MenuEditorPage({ menuName, title, description }: MenuEditorPageProps) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
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'
const [selectedMenuName, setSelectedMenuName] = useState('main')
const [selectedFeaturedIndex, setSelectedFeaturedIndex] = useState(0)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -141,30 +365,40 @@ export default function MenuManagerPage() {
.finally(() => setLoading(false))
}, [])
const currentMenu = menus.find(m => m.name === selectedMenuName) || { id: 0, name: selectedMenuName, items: [], payload: [] }
const currentMenu = menus.find(m => m.name === menuName) || { id: 0, name: menuName, items: [], payload: [] }
const mainPayload = (currentMenu.payload || []) as any[];
const safeFeaturedIndex = mainPayload.length > 0 ? Math.min(selectedFeaturedIndex, mainPayload.length - 1) : null;
const selectedFeaturedNode = safeFeaturedIndex === null ? null : mainPayload[safeFeaturedIndex];
const handleUpdatePayload = (newPayload: any[]) => {
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, payload: newPayload } : m))
setMenus(menus.map(m => m.name === menuName ? { ...m, payload: newPayload } : m))
}
const handleUpdateFeaturedNode = (newNode: any) => {
if (safeFeaturedIndex === null) return;
const newPayload = [...mainPayload];
newPayload[safeFeaturedIndex] = newNode;
handleUpdatePayload(newPayload);
}
const handleAddItem = () => {
const newItems = [...currentMenu.items, { id: Date.now(), label: 'New Link', url: '/', order: currentMenu.items.length }]
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m))
// If menu doesn't exist yet in state
if (!menus.find(m => m.name === selectedMenuName)) {
setMenus([...menus, { id: Date.now(), name: selectedMenuName, items: newItems }])
if (!menus.find(m => m.name === menuName)) {
setMenus([...menus, { id: Date.now(), name: menuName, items: newItems }])
}
}
const handleUpdateItem = (idx: number, field: string, value: string) => {
const newItems = [...currentMenu.items]
newItems[idx] = { ...newItems[idx], [field]: value }
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m))
}
const handleRemoveItem = (idx: number) => {
const newItems = currentMenu.items.filter((_, i) => i !== idx)
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m))
}
const handleExport = () => {
@@ -173,7 +407,7 @@ export default function MenuManagerPage() {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `menu-${selectedMenuName}-snapshot-${new Date().toISOString().split('T')[0]}.json`
link.download = `menu-${menuName}-snapshot-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
@@ -197,10 +431,10 @@ export default function MenuManagerPage() {
const newItems = Array.isArray(parsed.items) ? parsed.items : []
const newPayload = parsed.payload || []
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems, payload: newPayload } : m))
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems, payload: newPayload } : m))
if (!menus.find(m => m.name === selectedMenuName)) {
setMenus([...menus, { id: Date.now(), name: selectedMenuName, items: newItems, payload: newPayload }])
if (!menus.find(m => m.name === menuName)) {
setMenus([...menus, { id: Date.now(), name: menuName, items: newItems, payload: newPayload }])
}
alert('Menu loaded from file! Click "Save Menu" to apply changes.')
@@ -221,11 +455,11 @@ export default function MenuManagerPage() {
setSaving(true)
try {
const body = selectedMenuName === 'main'
const body = menuName === '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/${menuName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -255,24 +489,11 @@ export default function MenuManagerPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-7xl animate-fade-in">
<div className="lg:col-span-2 space-y-8">
<div className="space-y-1">
<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>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-slate-500 text-sm">{description}</p>
</div>
<div className="flex gap-4 border-b border-white/5 pb-4">
<div className="flex-1 flex gap-4">
{['main', 'footer-explore', 'footer-support'].map(name => (
<button
key={name}
onClick={() => setSelectedMenuName(name)}
className={`px-4 py-2 font-bold text-xs uppercase tracking-widest rounded-lg transition-colors ${
selectedMenuName === name ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'
}`}
>
{name === 'main' ? 'Main Navigation' : name === 'footer-explore' ? 'Footer Explore' : 'Footer Support'}
</button>
))}
</div>
<div className="flex justify-end gap-2 border-b border-white/5 pb-4">
<div className="flex gap-2">
<input
type="file"
@@ -302,9 +523,9 @@ export default function MenuManagerPage() {
<div className="admin-card p-8 space-y-6">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
Menu Items ({selectedMenuName})
Menu Items ({menuName})
</h2>
{selectedMenuName !== 'main' && (
{menuName !== 'main' && (
<button
type="button"
onClick={handleAddItem}
@@ -315,7 +536,7 @@ export default function MenuManagerPage() {
)}
</div>
{selectedMenuName === 'main' ? (
{menuName === '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>
@@ -324,6 +545,7 @@ export default function MenuManagerPage() {
onClick={() => {
const newPayload = [...(currentMenu.payload || []), { id: Date.now().toString(), label: { en: 'New Menu' }, url: '/', columns: [] }]
handleUpdatePayload(newPayload)
setSelectedFeaturedIndex(newPayload.length - 1)
}}
className="flex items-center gap-2 text-xs font-bold text-cyan-glow hover:underline"
>
@@ -348,7 +570,10 @@ export default function MenuManagerPage() {
onRemove={() => {
const p = (currentMenu.payload || []).filter((_: any, i: number) => i !== idx);
handleUpdatePayload(p);
setSelectedFeaturedIndex(current => Math.max(0, Math.min(current, p.length - 1)));
}}
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx}
/>
))
)}
@@ -444,8 +669,13 @@ export default function MenuManagerPage() {
</form>
</div>
{/* Sidebar for Dynamic Pages */}
<div className="space-y-6">
{menuName === 'main' ? (
<FeaturedCardSidePanel
node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode}
/>
) : (
<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">
@@ -487,6 +717,7 @@ export default function MenuManagerPage() {
)}
</div>
</div>
)}
</div>
{/* Toast Notification */}
@@ -3,7 +3,20 @@
import Link from '../LocalizedLink'
import Image from 'next/image'
import { useState, useRef } from 'react'
import type { MegaMenuPayload } from './menuTypes'
import {
ArrowRight,
Award,
Box,
ChevronRight,
Gift,
Leaf,
Package,
ShoppingBag,
Sparkles,
Star,
type LucideIcon,
} from 'lucide-react'
import type { MegaMenuPayload, MenuColumn } from './menuTypes'
import { getLocalizedMenuText } from './menuTypes'
interface DesktopMegaMenuProps {
@@ -12,6 +25,27 @@ interface DesktopMegaMenuProps {
currentPath: string
}
const MENU_ICONS: LucideIcon[] = [
ShoppingBag,
Sparkles,
Leaf,
Package,
Gift,
Award,
]
const MAX_TOP_LEVEL_LINKS = 5
const MAX_INLINE_CHILD_LINKS = 3
const getMoreLabel = (count: number, lang: string) => {
if (lang === 'th') return `+ อีก ${count} รายการ`
if (lang === 'ja') return `+ 他${count}件`
if (lang === 'zh') return `+ 还有 ${count} 项`
if (lang === 'vi') return `+ ${count} mục khác`
if (lang === 'ko') return `+ ${count}개 더`
return `+ ${count} more`
}
// Render a 1:1 image using next/image for optimization (website uses CDN images)
function MenuImage({
src,
@@ -64,6 +98,14 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
}, 150)
}
const getFirstImageLink = (columns: MenuColumn[] = []) => {
for (const column of columns) {
const found = column.links.find((link) => link.imageUrl)
if (found) return found
}
return columns[0]?.links[0]
}
return (
<div className="hidden space-x-7 text-sm font-medium uppercase tracking-wide md:flex">
{items.map((item, index) => {
@@ -72,9 +114,22 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const hasColumns = !!item.columns?.length
const isActive = item.url ? currentPath === item.url : false
const hasAnyColumnIcon = item.columns?.some(col => !!col.iconUrl)
const hasAnyLinkImage = item.columns?.some(col => col.links.some(l => !!l.imageUrl))
const showRightPanel = item.rootImageUrl || hasAnyLinkImage
const visibleColumns = item.columns?.slice(0, 2) ?? []
const featuredLink = getFirstImageLink(item.columns)
const featuredConfig = item.featured
const showFeatured = featuredConfig?.enabled !== false
const configuredImage = featuredConfig?.imageUrl || null
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl
const featuredHeading = getLocalizedMenuText(featuredConfig?.sectionTitle, lang) || 'Featured'
const featuredBadge = getLocalizedMenuText(featuredConfig?.badge, lang) || 'Recommended'
const featuredTitle = getLocalizedMenuText(featuredConfig?.title, lang)
|| getLocalizedMenuText(featuredLink?.label, lang)
|| getLocalizedMenuText(item.label, lang)
const featuredSubtitle = getLocalizedMenuText(featuredConfig?.subtitle, lang) || (item.columns?.[0]
? getLocalizedMenuText(item.columns[0].title, lang)
: getLocalizedMenuText(item.label, lang))
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'
const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details'
return (
<div
@@ -106,76 +161,150 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
{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'
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 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-surface-card/95 shadow-card backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-start gap-10 px-6 py-10">
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}>
{visibleColumns.map((column, columnIndex) => (
<div
key={columnIndex}
className={`pr-8 ${columnIndex > 0 ? 'border-l border-line-primary/15 pl-8' : ''}`}
>
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-text-main">
{(() => {
const HeadingIcon = MENU_ICONS[columnIndex] ?? Sparkles
return <HeadingIcon className="h-4 w-4 text-brand-primary" strokeWidth={1.5} />
})()}
{getLocalizedMenuText(column.title, lang)}
</h4>
<ul className="space-y-2">
{column.links.slice(0, MAX_TOP_LEVEL_LINKS).map((link, linkIndex) => {
const RowIcon = MENU_ICONS[(columnIndex * 3 + linkIndex + 1) % MENU_ICONS.length] ?? Box
const hasNestedLinks = !!link.links?.length
const visibleChildLinks = link.links?.slice(0, MAX_INLINE_CHILD_LINKS) ?? []
const hiddenChildCount = Math.max((link.links?.length ?? 0) - MAX_INLINE_CHILD_LINKS, 0)
const moreChildHref = link.url || link.links?.[MAX_INLINE_CHILD_LINKS]?.url || link.links?.[0]?.url || '#'
{/* LEFT: Root menu image (1:1) */}
{item.rootImageUrl && (
<div className="relative hidden h-56 w-56 shrink-0 overflow-hidden rounded-xl border border-black/8 lg:block">
<MenuImage
src={item.rootImageUrl}
alt={getLocalizedMenuText(item.label, lang)}
/>
</div>
)}
{/* CENTER: Columns */}
<div className="flex flex-1 gap-12">
{item.columns!.map((column, columnIndex) => (
<div key={columnIndex} className="flex min-w-[160px] gap-4 text-left items-start">
{/* Column icon (1:1) */}
{hasAnyColumnIcon && (
<div className="relative h-14 w-14 shrink-0 mt-0.5 overflow-hidden rounded-lg border border-black/8 bg-surface-muted">
<MenuImage
src={column.iconUrl}
alt={getLocalizedMenuText(column.title, lang)}
/>
</div>
)}
{/* Title and links */}
<div className="flex flex-col">
<h4 className="mb-3 text-[10px] font-bold uppercase tracking-widest text-text-main/50">
{getLocalizedMenuText(column.title, lang)}
</h4>
<ul className="space-y-2.5">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
return (
<li key={linkIndex}>
<div
className="rounded-md"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
href={link.url || '#'}
className="block text-sm font-normal normal-case tracking-normal text-text-main/65 transition-colors duration-150 hover:text-brand-primary"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
href={link.url || link.links?.[0]?.url || '#'}
className="group flex items-center gap-4 rounded-md px-1 py-2.5 text-left transition-colors hover:bg-surface-muted"
>
{getLocalizedMenuText(link.label, lang)}
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary transition-colors group-hover:border-brand-primary/40">
<RowIcon className="h-5 w-5" strokeWidth={1.35} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold normal-case tracking-normal text-text-main">
{getLocalizedMenuText(link.label, lang)}
</span>
<span className="mt-0.5 block truncate text-xs font-normal normal-case tracking-normal text-muted-text">
{getLocalizedMenuText(column.title, lang)}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-text-main/40 transition-colors group-hover:text-brand-primary" />
</Link>
</li>
))}
</ul>
</div>
</div>
))}
</div>
{/* RIGHT: Link hover preview / Root fallback (1:1) */}
{showRightPanel && (
<div className="relative hidden h-56 w-56 shrink-0 overflow-hidden rounded-xl border border-black/8 bg-surface-muted lg:flex items-center justify-center">
<MenuImage
src={hoveredLinkImage ?? item.rootImageUrl}
alt="Preview"
placeholder="Hover a link to preview"
/>
{hasNestedLinks && (
<ul className="ml-[3.25rem] mt-1 space-y-1 border-l border-line-primary/15 pl-3">
{visibleChildLinks.map((childLink, childIndex) => (
<li key={childIndex}>
<Link
href={childLink.url || '#'}
className="block rounded-sm px-2 py-1.5 text-xs font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
onMouseEnter={() => setHoveredLinkImage(childLink.imageUrl || link.imageUrl || null)}
>
{getLocalizedMenuText(childLink.label, lang)}
</Link>
</li>
))}
{hiddenChildCount > 0 && (
<li>
<Link
href={moreChildHref}
className="block rounded-sm px-2 py-1.5 text-xs font-semibold normal-case tracking-normal text-brand-primary transition-colors hover:bg-surface-muted"
>
{getMoreLabel(hiddenChildCount, lang)}
</Link>
</li>
)}
</ul>
)}
</div>
</li>
)
})}
</ul>
<Link
href={item.url || '#'}
className="mt-7 flex items-center justify-between text-xs font-medium normal-case tracking-normal text-text-main/65 transition-colors hover:text-brand-primary"
>
<span>{getLocalizedMenuText(item.label, lang)}</span>
<ArrowRight className="h-4 w-4" />
</Link>
</div>
))}
{showFeatured && (
<div className="border-l border-line-primary/15 pl-8">
<h4 className="mb-5 flex items-center gap-3 text-xs font-bold normal-case tracking-normal text-text-main">
<Star className="h-4 w-4 text-brand-primary" strokeWidth={1.5} />
{featuredHeading}
</h4>
<Link
href={featuredHref}
className="group block overflow-hidden rounded-lg border border-line-primary/20 bg-surface-page transition-colors hover:border-brand-primary/40"
onMouseEnter={() => setHoveredLinkImage(configuredImage ?? featuredLink?.imageUrl ?? null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<div className="relative h-40 w-full overflow-hidden bg-surface-muted">
<MenuImage
src={featuredImage}
alt={featuredTitle}
placeholder={getLocalizedMenuText(item.label, lang)}
/>
</div>
<div className="space-y-2 p-4">
{featuredBadge && (
<span className="inline-flex rounded-full bg-surface-card px-2.5 py-1 text-[10px] font-bold normal-case tracking-normal text-brand-primary">
{featuredBadge}
</span>
)}
<div>
<p className="line-clamp-2 text-sm font-bold normal-case tracking-normal text-text-main">
{featuredTitle}
</p>
{featuredSubtitle && (
<p className="mt-1 line-clamp-1 text-xs font-normal normal-case tracking-normal text-muted-text">
{featuredSubtitle}
</p>
)}
{featuredConfig?.price && (
<p className="mt-2 text-sm font-bold normal-case tracking-normal text-text-main">
{featuredConfig.price}
</p>
)}
</div>
<span className="flex w-full items-center justify-center rounded-full border border-brand-primary/40 px-4 py-2 text-xs font-bold normal-case tracking-normal text-text-main transition-colors group-hover:bg-brand-primary group-hover:text-surface-card">
{featuredCta}
</span>
</div>
</Link>
</div>
)}
</div>
</div>
</div>
@@ -11,6 +11,18 @@ export interface MenuColumn {
iconUrl?: string | null // รูปประจำคอลัมน์ (1:1)
}
export interface MenuFeaturedCard {
enabled?: boolean
sectionTitle?: Record<string, string> | string
badge?: Record<string, string> | string
title?: Record<string, string> | string
subtitle?: Record<string, string> | string
price?: string
imageUrl?: string | null
url?: string
ctaLabel?: Record<string, string> | string
}
export interface MegaMenuPayload {
id?: string
nodeId?: string
@@ -18,6 +30,7 @@ export interface MegaMenuPayload {
url?: string
columns?: MenuColumn[]
rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard
}
export function getLocalizedMenuText(text: Record<string, string> | string | undefined, lang: string) {