x
This commit is contained in:
1 parent
d8cca4fcfe
commit
52a5217d9b
8 files changed
+554
-107
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)
|
||||
}
|
||||
+272
-41
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user