From 20f43bd58dd67ae00ac892e20007217f278dbfa1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 13 Jun 2026 19:45:46 -0400 Subject: [PATCH] x --- apps/admin/src/App.tsx | 4 + apps/admin/src/components/Sidebar.tsx | 2 + apps/admin/src/pages/MenuManagerPage.tsx | 168 ++++++++++++++++++ .../src/pages/NewsletterSubscribersPage.tsx | 115 ++++++++++++ apps/admin/src/pages/SystemSettingsPage.tsx | 143 ++++++++++++++- apps/backend/prisma/schema.prisma | 35 +++- apps/backend/src/index.ts | 4 +- apps/backend/src/routes/menus.ts | 96 ++++++++++ apps/backend/src/routes/newsletter.ts | 36 ++++ apps/backend/src/routes/settings.ts | 10 +- apps/spa/src/components/Footer.tsx | 150 +++++++++++++--- apps/spa/src/components/Navbar.tsx | 47 ++++- packages/types/index.ts | 21 +++ 13 files changed, 789 insertions(+), 42 deletions(-) create mode 100644 apps/admin/src/pages/MenuManagerPage.tsx create mode 100644 apps/admin/src/pages/NewsletterSubscribersPage.tsx create mode 100644 apps/backend/src/routes/menus.ts create mode 100644 apps/backend/src/routes/newsletter.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 24bc5a6..d6e014f 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -13,6 +13,8 @@ import ProtectedRoute from './components/ProtectedRoute' import OrdersPage from './pages/OrdersPage' import OrderDetailPage from './pages/OrderDetailPage' import SystemSettingsPage from './pages/SystemSettingsPage' +import MenuManagerPage from './pages/MenuManagerPage' +import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage' import ArticlesPage from './pages/ArticlesPage' import ArticleEditPage from './pages/ArticleEditPage' import CouponsPage from './pages/CouponsPage' @@ -54,6 +56,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 9a5aeff..2dde2b6 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -41,6 +41,8 @@ const MENU_ITEMS = [ { 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: Settings, label: 'Settings', to: '/settings' }, ] diff --git a/apps/admin/src/pages/MenuManagerPage.tsx b/apps/admin/src/pages/MenuManagerPage.tsx new file mode 100644 index 0000000..3182a84 --- /dev/null +++ b/apps/admin/src/pages/MenuManagerPage.tsx @@ -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([]) + + // 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 ( +
+ +
+ ) + } + + return ( +
+
+

Menu Manager

+

Manage navigation links for the main navbar and footer.

+
+ +
+ {['main', 'footer-explore'].map(name => ( + + ))} +
+ +
+
+
+

+ Menu Items ({selectedMenuName}) +

+ +
+ +
+ {currentMenu.items.length === 0 ? ( +

No items in this menu.

+ ) : ( + currentMenu.items.map((item, idx) => ( +
+ + +
+
+ + handleUpdateItem(idx, 'label', e.target.value)} + className="admin-input w-full" + placeholder="e.g. Shop All" + /> +
+
+ + handleUpdateItem(idx, 'url', e.target.value)} + className="admin-input w-full" + placeholder="e.g. /shop" + /> +
+
+ + +
+ )) + )} +
+
+ + +
+
+ ) +} diff --git a/apps/admin/src/pages/NewsletterSubscribersPage.tsx b/apps/admin/src/pages/NewsletterSubscribersPage.tsx new file mode 100644 index 0000000..ea3431a --- /dev/null +++ b/apps/admin/src/pages/NewsletterSubscribersPage.tsx @@ -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([]) + 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 ( +
+ +
+ ) + } + + return ( +
+
+
+

+ + Newsletter Subscribers +

+

View and export all email subscribers.

+
+ + +
+ +
+ + + + + + + + + + + {subscribers.length === 0 ? ( + + + + ) : ( + subscribers.map(sub => ( + + + + + + + )) + )} + +
IDEmailDate SubscribedStatus
+ No subscribers yet. +
#{sub.id}{sub.email} + {new Date(sub.createdAt).toLocaleDateString()} {new Date(sub.createdAt).toLocaleTimeString()} + + {sub.isActive ? ( + + Active + + ) : ( + + Unsubscribed + + )} +
+
+
+ ) +} diff --git a/apps/admin/src/pages/SystemSettingsPage.tsx b/apps/admin/src/pages/SystemSettingsPage.tsx index d753305..3faa346 100644 --- a/apps/admin/src/pages/SystemSettingsPage.tsx +++ b/apps/admin/src/pages/SystemSettingsPage.tsx @@ -12,16 +12,33 @@ export default function SystemSettingsPage() { const [bankAccountName, setBankAccountName] = useState('') const [bankAccountNumber, setBankAccountNumber] = useState('') const [pointsEarnRate, setPointsEarnRate] = useState('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(() => { fetch(`${API_BASE}/settings`) .then(r => r.json()) .then((data: StoreSettings) => { setShippingFee(data.shippingFee.toString()) - setBankName(data.bankName) - setBankAccountName(data.bankAccountName) - setBankAccountNumber(data.bankAccountNumber) - setPointsEarnRate(data.pointsEarnRate.toString()) + setBankName(data.bankName || '') + setBankAccountName(data.bankAccountName || '') + setBankAccountNumber(data.bankAccountNumber || '') + 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) .finally(() => setLoading(false)) @@ -40,7 +57,15 @@ export default function SystemSettingsPage() { bankName, bankAccountName, bankAccountNumber, - pointsEarnRate: parseFloat(pointsEarnRate) || 1 + pointsEarnRate: parseFloat(pointsEarnRate) || 1, + footerDescription, + facebookUrl, + instagramUrl, + tiktokUrl, + newsletterTitle, + newsletterDescription, + newsletterPlaceholder, + newsletterButtonText }) }) if (res.ok) alert('Settings saved successfully!') @@ -166,6 +191,114 @@ export default function SystemSettingsPage() { + {/* Brand & Footer Settings */} +
+
+
+ ✨ +
+

Brand & Footer Settings

+
+ +
+
+ +