x
This commit is contained in:
1 parent
59363616c4
commit
20f43bd58d
13 files changed
+768
-21
No files matched your search
@@ -13,6 +13,8 @@ import ProtectedRoute from './components/ProtectedRoute'
|
|||||||
import OrdersPage from './pages/OrdersPage'
|
import OrdersPage from './pages/OrdersPage'
|
||||||
import OrderDetailPage from './pages/OrderDetailPage'
|
import OrderDetailPage from './pages/OrderDetailPage'
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage'
|
import SystemSettingsPage from './pages/SystemSettingsPage'
|
||||||
|
import MenuManagerPage from './pages/MenuManagerPage'
|
||||||
|
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
|
||||||
import ArticlesPage from './pages/ArticlesPage'
|
import ArticlesPage from './pages/ArticlesPage'
|
||||||
import ArticleEditPage from './pages/ArticleEditPage'
|
import ArticleEditPage from './pages/ArticleEditPage'
|
||||||
import CouponsPage from './pages/CouponsPage'
|
import CouponsPage from './pages/CouponsPage'
|
||||||
@@ -54,6 +56,8 @@ function App() {
|
|||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||||
<Route path="/settings" element={<SystemSettingsPage />} />
|
<Route path="/settings" element={<SystemSettingsPage />} />
|
||||||
|
<Route path="/menu-manager" element={<MenuManagerPage />} />
|
||||||
|
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
|
||||||
<Route path="/articles" element={<ArticlesPage />} />
|
<Route path="/articles" element={<ArticlesPage />} />
|
||||||
<Route path="/articles/:id" element={<ArticleEditPage />} />
|
<Route path="/articles/:id" element={<ArticleEditPage />} />
|
||||||
<Route path="/tiktok" element={<TiktokPage />} />
|
<Route path="/tiktok" element={<TiktokPage />} />
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const MENU_ITEMS = [
|
|||||||
{ icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' },
|
{ icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' },
|
||||||
{ icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' },
|
{ icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' },
|
||||||
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
|
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
|
||||||
|
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
|
||||||
|
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
|
||||||
{ icon: Settings, label: 'Settings', to: '/settings' },
|
{ icon: Settings, label: 'Settings', to: '/settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { API_BASE, type Menu } from '@homaroma/types'
|
||||||
|
import { Save, Loader2, Plus, Trash2, GripVertical } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function MenuManagerPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [menus, setMenus] = useState<Menu[]>([])
|
||||||
|
|
||||||
|
// We'll manage multiple menus, e.g., 'main' and 'footer-explore'
|
||||||
|
const [selectedMenuName, setSelectedMenuName] = useState('main')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/menus`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: Menu[]) => {
|
||||||
|
setMenus(data)
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const currentMenu = menus.find(m => m.name === selectedMenuName) || { id: 0, name: selectedMenuName, items: [] }
|
||||||
|
|
||||||
|
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))
|
||||||
|
// If menu doesn't exist yet in state
|
||||||
|
if (!menus.find(m => m.name === selectedMenuName)) {
|
||||||
|
setMenus([...menus, { id: Date.now(), name: selectedMenuName, 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveItem = (idx: number) => {
|
||||||
|
const newItems = currentMenu.items.filter((_, i) => i !== idx)
|
||||||
|
setMenus(menus.map(m => m.name === selectedMenuName ? { ...m, items: newItems } : m))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/menus/${selectedMenuName}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: currentMenu.items })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Menu saved successfully!')
|
||||||
|
} else {
|
||||||
|
alert('Failed to save menu')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert('Error saving menu')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-8 animate-fade-in">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 border-b border-white/5 pb-4">
|
||||||
|
{['main', 'footer-explore'].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' : 'Footer Explore'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
<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})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{currentMenu.items.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic">No items in this menu.</p>
|
||||||
|
) : (
|
||||||
|
currentMenu.items.map((item, idx) => (
|
||||||
|
<div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group">
|
||||||
|
<GripVertical className="w-5 h-5 text-slate-600 cursor-move" />
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-500">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.label}
|
||||||
|
onChange={e => handleUpdateItem(idx, 'label', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="e.g. Shop All"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-slate-500">URL path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.url}
|
||||||
|
onChange={e => handleUpdateItem(idx, 'url', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="e.g. /shop"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveItem(idx)}
|
||||||
|
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||||
|
title="Remove item"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary py-4 px-12 flex items-center gap-2 ml-auto text-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Menu
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
|
import { Download, Loader2, Mail } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Subscriber {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsletterSubscribersPage() {
|
||||||
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/newsletter/subscribers`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setSubscribers(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const csvContent = "data:text/csv;charset=utf-8,"
|
||||||
|
+ "ID,Email,Date\n"
|
||||||
|
+ subscribers.map(e => `${e.id},${e.email},${new Date(e.createdAt).toLocaleString()}`).join("\n");
|
||||||
|
|
||||||
|
const encodedUri = encodeURI(csvContent);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", encodedUri);
|
||||||
|
link.setAttribute("download", `subscribers_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-8 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||||
|
<Mail className="w-8 h-8 text-cyan-glow" />
|
||||||
|
Newsletter Subscribers
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 text-sm">View and export all email subscribers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={subscribers.length === 0}
|
||||||
|
className="btn-primary py-3 px-6 flex items-center gap-2 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card overflow-hidden">
|
||||||
|
<table className="w-full text-left text-sm text-slate-300">
|
||||||
|
<thead className="text-xs uppercase bg-white/5 border-b border-white/10 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-bold tracking-widest">ID</th>
|
||||||
|
<th className="px-6 py-4 font-bold tracking-widest">Email</th>
|
||||||
|
<th className="px-6 py-4 font-bold tracking-widest">Date Subscribed</th>
|
||||||
|
<th className="px-6 py-4 font-bold tracking-widest text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{subscribers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-6 py-12 text-center text-slate-500 italic">
|
||||||
|
No subscribers yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
subscribers.map(sub => (
|
||||||
|
<tr key={sub.id} className="hover:bg-white/5 transition-colors">
|
||||||
|
<td className="px-6 py-4 font-mono text-slate-500">#{sub.id}</td>
|
||||||
|
<td className="px-6 py-4 font-bold">{sub.email}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-400">
|
||||||
|
{new Date(sub.createdAt).toLocaleDateString()} {new Date(sub.createdAt).toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
{sub.isActive ? (
|
||||||
|
<span className="px-2 py-1 text-[10px] uppercase font-bold tracking-widest bg-cyan-glow/20 text-cyan-glow rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 text-[10px] uppercase font-bold tracking-widest bg-rose-500/20 text-rose-500 rounded-full">
|
||||||
|
Unsubscribed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,16 +12,33 @@ export default function SystemSettingsPage() {
|
|||||||
const [bankAccountName, setBankAccountName] = useState('')
|
const [bankAccountName, setBankAccountName] = useState('')
|
||||||
const [bankAccountNumber, setBankAccountNumber] = useState('')
|
const [bankAccountNumber, setBankAccountNumber] = useState('')
|
||||||
const [pointsEarnRate, setPointsEarnRate] = useState<string>('1')
|
const [pointsEarnRate, setPointsEarnRate] = useState<string>('1')
|
||||||
|
const [footerDescription, setFooterDescription] = useState('')
|
||||||
|
const [facebookUrl, setFacebookUrl] = useState('')
|
||||||
|
const [instagramUrl, setInstagramUrl] = useState('')
|
||||||
|
const [tiktokUrl, setTiktokUrl] = useState('')
|
||||||
|
|
||||||
|
const [newsletterTitle, setNewsletterTitle] = useState('')
|
||||||
|
const [newsletterDescription, setNewsletterDescription] = useState('')
|
||||||
|
const [newsletterPlaceholder, setNewsletterPlaceholder] = useState('')
|
||||||
|
const [newsletterButtonText, setNewsletterButtonText] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/settings`)
|
fetch(`${API_BASE}/settings`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((data: StoreSettings) => {
|
.then((data: StoreSettings) => {
|
||||||
setShippingFee(data.shippingFee.toString())
|
setShippingFee(data.shippingFee.toString())
|
||||||
setBankName(data.bankName)
|
setBankName(data.bankName || '')
|
||||||
setBankAccountName(data.bankAccountName)
|
setBankAccountName(data.bankAccountName || '')
|
||||||
setBankAccountNumber(data.bankAccountNumber)
|
setBankAccountNumber(data.bankAccountNumber || '')
|
||||||
setPointsEarnRate(data.pointsEarnRate.toString())
|
setPointsEarnRate((data.pointsEarnRate || 1).toString())
|
||||||
|
setFooterDescription(data.footerDescription || '')
|
||||||
|
setFacebookUrl(data.facebookUrl || '')
|
||||||
|
setInstagramUrl(data.instagramUrl || '')
|
||||||
|
setTiktokUrl(data.tiktokUrl || '')
|
||||||
|
setNewsletterTitle(data.newsletterTitle || '')
|
||||||
|
setNewsletterDescription(data.newsletterDescription || '')
|
||||||
|
setNewsletterPlaceholder(data.newsletterPlaceholder || '')
|
||||||
|
setNewsletterButtonText(data.newsletterButtonText || '')
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -40,7 +57,15 @@ export default function SystemSettingsPage() {
|
|||||||
bankName,
|
bankName,
|
||||||
bankAccountName,
|
bankAccountName,
|
||||||
bankAccountNumber,
|
bankAccountNumber,
|
||||||
pointsEarnRate: parseFloat(pointsEarnRate) || 1
|
pointsEarnRate: parseFloat(pointsEarnRate) || 1,
|
||||||
|
footerDescription,
|
||||||
|
facebookUrl,
|
||||||
|
instagramUrl,
|
||||||
|
tiktokUrl,
|
||||||
|
newsletterTitle,
|
||||||
|
newsletterDescription,
|
||||||
|
newsletterPlaceholder,
|
||||||
|
newsletterButtonText
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (res.ok) alert('Settings saved successfully!')
|
if (res.ok) alert('Settings saved successfully!')
|
||||||
@@ -166,6 +191,114 @@ export default function SystemSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Brand & Footer Settings */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
|
||||||
|
<div className="p-2 bg-purple-500/10 rounded-lg">
|
||||||
|
<span className="text-xl">✨</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand & Footer Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Footer Description</label>
|
||||||
|
<textarea
|
||||||
|
value={footerDescription}
|
||||||
|
onChange={e => setFooterDescription(e.target.value)}
|
||||||
|
className="admin-input w-full min-h-[80px]"
|
||||||
|
placeholder="Where essential oils meet modern living..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Facebook URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={facebookUrl}
|
||||||
|
onChange={e => setFacebookUrl(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="https://facebook.com/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Instagram URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instagramUrl}
|
||||||
|
onChange={e => setInstagramUrl(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">TikTok URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tiktokUrl}
|
||||||
|
onChange={e => setTiktokUrl(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="https://tiktok.com/@..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newsletter Settings */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
|
||||||
|
<div className="p-2 bg-cyan-glow/10 rounded-lg">
|
||||||
|
<span className="text-xl">📧</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Newsletter Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Newsletter Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newsletterTitle}
|
||||||
|
onChange={e => setNewsletterTitle(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Newsletter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Value Proposition (Description)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newsletterDescription}
|
||||||
|
onChange={e => setNewsletterDescription(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Subscribe for exclusive releases..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Input Placeholder Text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newsletterPlaceholder}
|
||||||
|
onChange={e => setNewsletterPlaceholder(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Your email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Submit Button Text</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newsletterButtonText}
|
||||||
|
onChange={e => setNewsletterButtonText(e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Join"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -155,9 +155,24 @@ model StoreSettings {
|
|||||||
bankAccountName String @default("HomAroma Co., Ltd.")
|
bankAccountName String @default("HomAroma Co., Ltd.")
|
||||||
bankAccountNumber String @default("012-3-45678-9")
|
bankAccountNumber String @default("012-3-45678-9")
|
||||||
pointsEarnRate Float @default(1.0)
|
pointsEarnRate Float @default(1.0)
|
||||||
|
facebookUrl String?
|
||||||
|
footerDescription String?
|
||||||
|
instagramUrl String?
|
||||||
|
tiktokUrl String?
|
||||||
|
newsletterTitle String?
|
||||||
|
newsletterDescription String?
|
||||||
|
newsletterPlaceholder String?
|
||||||
|
newsletterButtonText String?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NewsletterSubscriber {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
customerName String
|
customerName String
|
||||||
@@ -468,3 +483,21 @@ model NewsletterSubscription {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Menu {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique // e.g., "header", "footer"
|
||||||
|
items MenuItem[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model MenuItem {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
menuId Int
|
||||||
|
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||||
|
label String
|
||||||
|
url String
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import { pagesRoute } from "./routes/pages";
|
|||||||
import { paymentsRoute } from "./routes/payments";
|
import { paymentsRoute } from "./routes/payments";
|
||||||
import { profileRoute } from "./routes/profile";
|
import { profileRoute } from "./routes/profile";
|
||||||
import { lineRoutes } from "./routes/line";
|
import { lineRoutes } from "./routes/line";
|
||||||
|
import { menusRoute } from "./routes/menus";
|
||||||
|
import { newsletterRoute } from "./routes/newsletter";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||||
@@ -97,7 +99,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes)
|
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute)
|
||||||
)
|
)
|
||||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
|
export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||||
|
.get("/", async () => {
|
||||||
|
return await prisma.menu.findMany({
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.get("/:name", async ({ params: { name }, set }) => {
|
||||||
|
const menu = await prisma.menu.findUnique({
|
||||||
|
where: { name },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!menu) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Menu not found" };
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
})
|
||||||
|
.post("/", async ({ body, set }) => {
|
||||||
|
try {
|
||||||
|
const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] };
|
||||||
|
const menu = await prisma.menu.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
items: items ? {
|
||||||
|
create: items
|
||||||
|
} : undefined
|
||||||
|
},
|
||||||
|
include: { items: true }
|
||||||
|
});
|
||||||
|
return menu;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to create menu" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
items: t.Optional(t.Array(t.Object({
|
||||||
|
label: t.String(),
|
||||||
|
url: t.String(),
|
||||||
|
order: t.Number()
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.put("/:name", async ({ params: { name }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const { items } = body as { items: { id?: number; label: string; url: string; order: number }[] };
|
||||||
|
let menu = await prisma.menu.findUnique({ where: { name } });
|
||||||
|
|
||||||
|
if (!menu) {
|
||||||
|
menu = await prisma.menu.create({ data: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.menuItem.deleteMany({ where: { menuId: menu.id } }),
|
||||||
|
prisma.menuItem.createMany({
|
||||||
|
data: items.map(item => ({
|
||||||
|
menuId: menu.id,
|
||||||
|
label: item.label,
|
||||||
|
url: item.url,
|
||||||
|
order: item.order
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return await prisma.menu.findUnique({
|
||||||
|
where: { name },
|
||||||
|
include: { items: { orderBy: { order: "asc" } } }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to update menu" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
items: t.Array(t.Object({
|
||||||
|
id: t.Optional(t.Number()),
|
||||||
|
label: t.String(),
|
||||||
|
url: t.String(),
|
||||||
|
order: t.Number()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
|
export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
|
||||||
|
.post("/subscribe", async ({ body, set }) => {
|
||||||
|
try {
|
||||||
|
const { email } = body as { email: string };
|
||||||
|
|
||||||
|
const existing = await prisma.newsletterSubscriber.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { message: "Email already subscribed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.newsletterSubscriber.create({
|
||||||
|
data: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: "Successfully subscribed" };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to subscribe" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.String({ format: "email" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get("/subscribers", async () => {
|
||||||
|
return await prisma.newsletterSubscriber.findMany({
|
||||||
|
orderBy: { createdAt: "desc" }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,14 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
bankName: t.String(),
|
bankName: t.String(),
|
||||||
bankAccountName: t.String(),
|
bankAccountName: t.String(),
|
||||||
bankAccountNumber: t.String(),
|
bankAccountNumber: t.String(),
|
||||||
pointsEarnRate: t.Number()
|
pointsEarnRate: t.Number(),
|
||||||
|
facebookUrl: t.Optional(t.String()),
|
||||||
|
instagramUrl: t.Optional(t.String()),
|
||||||
|
tiktokUrl: t.Optional(t.String()),
|
||||||
|
footerDescription: t.Optional(t.String()),
|
||||||
|
newsletterTitle: t.Optional(t.String()),
|
||||||
|
newsletterDescription: t.Optional(t.String()),
|
||||||
|
newsletterPlaceholder: t.Optional(t.String()),
|
||||||
|
newsletterButtonText: t.Optional(t.String())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,65 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { Category } from '../types'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
categories?: Category[]
|
categories?: Category[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer({ categories = [] }: FooterProps) {
|
export default function Footer({ categories = [] }: FooterProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
|
||||||
|
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [subscribing, setSubscribing] = useState(false)
|
||||||
|
const [subscribeStatus, setSubscribeStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||||
|
const [subscribeMessage, setSubscribeMessage] = useState('')
|
||||||
|
|
||||||
|
const handleSubscribe = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email) return
|
||||||
|
setSubscribing(true)
|
||||||
|
setSubscribeStatus('idle')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/newsletter/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
setSubscribeStatus('success')
|
||||||
|
setSubscribeMessage(data.message || 'Subscribed!')
|
||||||
|
setEmail('')
|
||||||
|
} else {
|
||||||
|
setSubscribeStatus('error')
|
||||||
|
setSubscribeMessage(data.error || data.message || 'Failed to subscribe')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSubscribeStatus('error')
|
||||||
|
setSubscribeMessage('An error occurred. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSubscribing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/menus/footer-explore`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.items) setExploreMenu(data.items.sort((a: any, b: any) => a.order - b.order))
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/settings`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data) setSettings(data)
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-black py-20 px-6 border-t border-white/5">
|
<footer className="bg-black py-20 px-6 border-t border-white/5">
|
||||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||||
@@ -16,26 +68,48 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
HomAroma <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 text-sm leading-relaxed">
|
<p className="text-slate-500 text-sm leading-relaxed">
|
||||||
{t('footer.catchphrase')}
|
{settings?.footerDescription || t('footer.catchphrase')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{['IG', 'FB', 'TW', 'YT'].map((s) => (
|
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
|
||||||
|
<>
|
||||||
|
{settings.facebookUrl && (
|
||||||
|
<a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">FB</a>
|
||||||
|
)}
|
||||||
|
{settings.instagramUrl && (
|
||||||
|
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">IG</a>
|
||||||
|
)}
|
||||||
|
{settings.tiktokUrl && (
|
||||||
|
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">TT</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||||
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">
|
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">
|
||||||
{s}
|
{s}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4>
|
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||||
|
{exploreMenu.length > 0 ? (
|
||||||
|
exploreMenu.map(item => (
|
||||||
|
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{item.label}</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Link to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
|
<Link to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
|
||||||
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
|
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
|
||||||
<Link to="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
|
<Link to="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
|
||||||
<Link to="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
|
<Link to="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
|
||||||
<Link to="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
|
<Link to="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
|
||||||
<Link to="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
|
<Link to="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,18 +125,42 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">Newsletter</h4>
|
<h4 className="font-bold uppercase tracking-widest text-xs text-white">
|
||||||
<p className="text-xs text-slate-500">Subscribe for exclusive releases and sensory wisdom.</p>
|
{settings?.newsletterTitle || 'Newsletter'}
|
||||||
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm">
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{settings?.newsletterDescription || 'Subscribe for exclusive releases and sensory wisdom.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{subscribeStatus === 'success' ? (
|
||||||
|
<div className="p-4 bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg text-cyan-glow text-sm text-center animate-fade-in">
|
||||||
|
✨ {subscribeMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubscribe} className="space-y-2">
|
||||||
|
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm focus-within:border-cyan-glow/50 transition-colors">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Your email"
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder={settings?.newsletterPlaceholder || "Your email"}
|
||||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
|
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
|
||||||
/>
|
/>
|
||||||
<button className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0">
|
<button
|
||||||
Join
|
type="submit"
|
||||||
|
disabled={subscribing}
|
||||||
|
className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{subscribing ? '...' : (settings?.newsletterButtonText || 'Join')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{subscribeStatus === 'error' && (
|
||||||
|
<p className="text-[10px] text-rose-500">{subscribeMessage}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { Category } from '../types'
|
import { API_BASE, type Category, type MenuItem } from '../types'
|
||||||
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart } from 'lucide-react'
|
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart } from 'lucide-react'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import { useWishlistStore } from '../store/wishlistStore'
|
import { useWishlistStore } from '../store/wishlistStore'
|
||||||
@@ -20,6 +20,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [localOrdersCount, setLocalOrdersCount] = useState(0)
|
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()
|
||||||
@@ -56,6 +57,19 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
try { setLocalOrdersCount(JSON.parse(stored).length) } catch (e) {}
|
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)
|
||||||
@@ -104,9 +118,19 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
|
|
||||||
{/* ── DESKTOP NAV ── */}
|
{/* ── DESKTOP NAV ── */}
|
||||||
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
<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}>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
|
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
|
||||||
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
|
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
|
||||||
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
|
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collections dropdown */}
|
{/* Collections dropdown */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
@@ -136,8 +160,12 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{menuItems.length === 0 && (
|
||||||
|
<>
|
||||||
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
|
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
|
||||||
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
|
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── DESKTOP RIGHT ACTIONS ── */}
|
{/* ── DESKTOP RIGHT ACTIONS ── */}
|
||||||
@@ -362,7 +390,10 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
|
|
||||||
{/* Drawer Nav Links */}
|
{/* Drawer Nav Links */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
||||||
{[
|
{(menuItems.length > 0 ? [
|
||||||
|
...menuItems.map(item => ({ to: item.url, label: item.label, end: item.url === '/' })),
|
||||||
|
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
||||||
|
] : [
|
||||||
{ to: '/', label: t('nav.home'), end: true },
|
{ to: '/', label: t('nav.home'), end: true },
|
||||||
{ to: '/shop', label: t('nav.shop') },
|
{ to: '/shop', label: t('nav.shop') },
|
||||||
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
||||||
@@ -371,7 +402,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
|||||||
{ to: '/about', label: t('nav.about') },
|
{ to: '/about', label: t('nav.about') },
|
||||||
{ to: '/faq', label: t('nav.faq') },
|
{ to: '/faq', label: t('nav.faq') },
|
||||||
{ to: '/contact', label: t('nav.contact') },
|
{ to: '/contact', label: t('nav.contact') },
|
||||||
].map(item => (
|
]).map((item: any) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
|||||||
@@ -94,6 +94,27 @@ export interface StoreSettings {
|
|||||||
bankAccountName: string
|
bankAccountName: string
|
||||||
bankAccountNumber: string
|
bankAccountNumber: string
|
||||||
pointsEarnRate: number
|
pointsEarnRate: number
|
||||||
|
footerDescription?: string | null
|
||||||
|
facebookUrl?: string | null
|
||||||
|
instagramUrl?: string | null
|
||||||
|
tiktokUrl?: string | null
|
||||||
|
newsletterTitle?: string | null
|
||||||
|
newsletterDescription?: string | null
|
||||||
|
newsletterPlaceholder?: string | null
|
||||||
|
newsletterButtonText?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
items: MenuItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
|
|||||||
Reference in new issue
Block a user