diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx
index d87c1e0..9236ed2 100644
--- a/apps/admin/src/App.tsx
+++ b/apps/admin/src/App.tsx
@@ -13,6 +13,11 @@ import ProtectedRoute from './components/ProtectedRoute'
import OrdersPage from './pages/OrdersPage'
import OrderDetailPage from './pages/OrderDetailPage'
import SystemSettingsPage from './pages/SystemSettingsPage'
+import StoreSettingsPage from './pages/StoreSettingsPage'
+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 NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
import ArticlesPage from './pages/ArticlesPage'
@@ -61,7 +66,11 @@ function App() {
} />
} />
} />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx
index f549d7e..1bd83a5 100644
--- a/apps/admin/src/components/Sidebar.tsx
+++ b/apps/admin/src/components/Sidebar.tsx
@@ -1,4 +1,5 @@
-import { NavLink, useNavigate } from 'react-router-dom'
+import { useState } from 'react'
+import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Package,
@@ -18,10 +19,16 @@ import {
Globe2,
Users,
Image as ImageIcon,
- MessageSquare
+ MessageSquare,
+ Store,
+ CreditCard,
+ Palette,
+ FileText,
+ Wrench,
+ ChevronDown,
} from 'lucide-react'
-const MENU_ITEMS = [
+const MAIN_MENU = [
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
{ icon: Tags, label: 'Categories', to: '/categories' },
{ icon: Package, label: 'Products', to: '/products' },
@@ -49,11 +56,21 @@ const MENU_ITEMS = [
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
{ icon: MessageSquare, label: 'Contact Messages', to: '/contacts' },
- { icon: Settings, label: 'Settings', to: '/settings' },
+]
+
+const SETTINGS_MENU = [
+ { icon: Store, label: 'Store Settings', to: '/settings' },
+ { icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' },
+ { icon: Palette, label: 'Theme & Design', to: '/settings/theme' },
+ { icon: FileText, label: 'Content', to: '/settings/content' },
+ { icon: Wrench, label: 'System', to: '/settings/system' },
]
export default function Sidebar() {
const navigate = useNavigate()
+ const location = useLocation()
+ const isInSettings = location.pathname.startsWith('/settings')
+ const [settingsOpen, setSettingsOpen] = useState(isInSettings)
const handleLogout = () => {
localStorage.removeItem('homaroma_admin_token')
@@ -72,12 +89,12 @@ export default function Sidebar() {
{/* Navigation — scrollable */}
{/* User / Bottom */}
diff --git a/apps/admin/src/pages/ContentSettingsPage.tsx b/apps/admin/src/pages/ContentSettingsPage.tsx
new file mode 100644
index 0000000..19367f8
--- /dev/null
+++ b/apps/admin/src/pages/ContentSettingsPage.tsx
@@ -0,0 +1,127 @@
+import { useState, useEffect } from 'react'
+import { API_BASE, type StoreSettings } from '@homaroma/types'
+import { Save, Loader2 } from 'lucide-react'
+
+export default function ContentSettingsPage() {
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ const [footerDescription, setFooterDescription] = useState('')
+ const [footerSupportHeading, setFooterSupportHeading] = useState('')
+ const [footerCopyrightText, setFooterCopyrightText] = 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) => {
+ setFooterDescription(data.footerDescription || '')
+ setFooterSupportHeading(data.footerSupportHeading || '')
+ setFooterCopyrightText(data.footerCopyrightText || '')
+ setNewsletterTitle(data.newsletterTitle || '')
+ setNewsletterDescription(data.newsletterDescription || '')
+ setNewsletterPlaceholder(data.newsletterPlaceholder || '')
+ setNewsletterButtonText(data.newsletterButtonText || '')
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [])
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const res = await fetch(`${API_BASE}/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ footerDescription, footerSupportHeading, footerCopyrightText,
+ newsletterTitle, newsletterDescription, newsletterPlaceholder, newsletterButtonText,
+ }),
+ })
+ if (res.ok) alert('Saved successfully!')
+ } catch { alert('Failed to save') } finally { setSaving(false) }
+ }
+
+ if (loading) return
+
+ return (
+
+
+
Content Settings
+
Newsletter copy and footer text displayed across the website.
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/pages/MediaLibraryPage.tsx b/apps/admin/src/pages/MediaLibraryPage.tsx
index f3632d5..126d0d5 100644
--- a/apps/admin/src/pages/MediaLibraryPage.tsx
+++ b/apps/admin/src/pages/MediaLibraryPage.tsx
@@ -1,6 +1,6 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
import { API_BASE } from '@homaroma/types';
-import { Upload, Loader2, Image as ImageIcon, Trash2 } from 'lucide-react';
+import { Upload, Loader2, Image as ImageIcon, Trash2, ClipboardPaste } from 'lucide-react';
interface MediaItem {
id?: string;
@@ -13,6 +13,8 @@ export default function MediaLibraryPage() {
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState(null);
+ const [pasteToast, setPasteToast] = useState(null);
+ const toastTimerRef = useRef | null>(null);
const fetchMedia = async () => {
try {
@@ -33,13 +35,15 @@ export default function MediaLibraryPage() {
fetchMedia();
}, []);
- const handleUpload = async (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
+ const showToast = (message: string) => {
+ setPasteToast(message);
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
+ toastTimerRef.current = setTimeout(() => setPasteToast(null), 3000);
+ };
+ const uploadFile = useCallback(async (file: File) => {
const formData = new FormData();
formData.append('file', file);
-
try {
setUploading(true);
const res = await fetch(`${API_BASE}/upload`, {
@@ -47,16 +51,56 @@ export default function MediaLibraryPage() {
body: formData,
});
if (res.ok) {
- await fetchMedia(); // Refresh list
+ await fetchMedia();
+ showToast(`✅ Uploaded: ${file.name || 'image'}`);
+ } else {
+ showToast('❌ Upload failed');
}
} catch (error) {
console.error('Failed to upload', error);
+ showToast('❌ Upload error');
} finally {
setUploading(false);
- e.target.value = ''; // Reset input
}
+ }, []);
+
+ const handleUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ await uploadFile(file);
+ e.target.value = ''; // Reset input
};
+ const handlePaste = useCallback(
+ async (e: ClipboardEvent) => {
+ if (uploading) return;
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (const item of Array.from(items)) {
+ if (item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (!file) continue;
+ // Give the pasted file a sensible name
+ const ext = item.type.split('/')[1] || 'png';
+ const namedFile = new File([file], `paste-${Date.now()}.${ext}`, { type: item.type });
+ showToast('📋 Pasting image...');
+ await uploadFile(namedFile);
+ break;
+ }
+ }
+ },
+ [uploading, uploadFile]
+ );
+
+ useEffect(() => {
+ document.addEventListener('paste', handlePaste);
+ return () => document.removeEventListener('paste', handlePaste);
+ }, [handlePaste]);
+
+ useEffect(() => {
+ return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); };
+ }, []);
+
const handleDelete = async (id: string | undefined, e: React.MouseEvent) => {
e.stopPropagation();
if (!id) return;
@@ -89,7 +133,30 @@ export default function MediaLibraryPage() {
return (
-
+ {/* Paste Toast Notification */}
+ {pasteToast && (
+
+ {pasteToast}
+
+ )}
+
+
@@ -108,6 +175,28 @@ export default function MediaLibraryPage() {
+ {/* Paste Hint Banner */}
+
+
+
+ Paste an image anywhere on this page (Ctrl+V / ⌘+V) to upload instantly
+
+
+
{loading ? (
diff --git a/apps/admin/src/pages/PaymentSettingsPage.tsx b/apps/admin/src/pages/PaymentSettingsPage.tsx
new file mode 100644
index 0000000..78c1873
--- /dev/null
+++ b/apps/admin/src/pages/PaymentSettingsPage.tsx
@@ -0,0 +1,121 @@
+import { useState, useEffect } from 'react'
+import { API_BASE, type StoreSettings } from '@homaroma/types'
+import { Save, Loader2, Landmark, Truck } from 'lucide-react'
+
+export default function PaymentSettingsPage() {
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ const [shippingFee, setShippingFee] = useState('')
+ const [bankName, setBankName] = useState('')
+ const [bankAccountName, setBankAccountName] = useState('')
+ const [bankAccountNumber, setBankAccountNumber] = useState('')
+ const [pointsEarnRate, setPointsEarnRate] = useState('1')
+
+ 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 || 1).toString())
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [])
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const res = await fetch(`${API_BASE}/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ shippingFee: parseFloat(shippingFee) || 0,
+ bankName, bankAccountName, bankAccountNumber,
+ pointsEarnRate: parseFloat(pointsEarnRate) || 1,
+ }),
+ })
+ if (res.ok) alert('Saved successfully!')
+ } catch { alert('Failed to save') } finally { setSaving(false) }
+ }
+
+ if (loading) return
+
+ return (
+
+
+
Payment & Shipping
+
Configure banking details, shipping fee and loyalty points.
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/pages/StoreSettingsPage.tsx b/apps/admin/src/pages/StoreSettingsPage.tsx
new file mode 100644
index 0000000..b4a3a7c
--- /dev/null
+++ b/apps/admin/src/pages/StoreSettingsPage.tsx
@@ -0,0 +1,128 @@
+import { useState, useEffect } from 'react'
+import { API_BASE, type StoreSettings } from '@homaroma/types'
+import { Save, Loader2 } from 'lucide-react'
+
+export default function StoreSettingsPage() {
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ const [brandName, setBrandName] = useState('')
+ const [logoUrl, setLogoUrl] = useState('')
+ const [themePreset, setThemePreset] = useState('default')
+ const [facebookUrl, setFacebookUrl] = useState('')
+ const [instagramUrl, setInstagramUrl] = useState('')
+ const [tiktokUrl, setTiktokUrl] = useState('')
+
+ useEffect(() => {
+ fetch(`${API_BASE}/settings`)
+ .then(r => r.json())
+ .then((data: StoreSettings) => {
+ setBrandName(data.brandName || 'HomAroma')
+ setLogoUrl(data.logoUrl || '')
+ setThemePreset(data.themePreset || 'default')
+ setFacebookUrl(data.facebookUrl || '')
+ setInstagramUrl(data.instagramUrl || '')
+ setTiktokUrl(data.tiktokUrl || '')
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ }, [])
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const res = await fetch(`${API_BASE}/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl }),
+ })
+ if (res.ok) alert('Saved successfully!')
+ } catch { alert('Failed to save') } finally { setSaving(false) }
+ }
+
+ if (loading) return
+
+ return (
+
+
+
Store Settings
+
Brand identity, logo, theme preset and social media links.
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/pages/SystemMaintenancePage.tsx b/apps/admin/src/pages/SystemMaintenancePage.tsx
new file mode 100644
index 0000000..b99611a
--- /dev/null
+++ b/apps/admin/src/pages/SystemMaintenancePage.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { API_BASE } from '@homaroma/types'
+import { Loader2, DatabaseZap, CheckCircle2, AlertCircle } from 'lucide-react'
+
+type SeedResult = { success: boolean; message: string; results?: { slug: string; status: string }[] }
+
+export default function SystemMaintenancePage() {
+ const [seeding, setSeeding] = useState(false)
+ const [seedResult, setSeedResult] = useState
(null)
+
+ const handleSeedPages = async () => {
+ setSeeding(true)
+ setSeedResult(null)
+ try {
+ const res = await fetch(`${API_BASE}/settings/seed-pages`, { method: 'POST' })
+ const data = await res.json()
+ if (res.ok && data.success) {
+ setSeedResult({ success: true, message: data.message, results: data.results })
+ } else {
+ setSeedResult({ success: false, message: data.error || 'Seed failed' })
+ }
+ } catch {
+ setSeedResult({ success: false, message: 'Network error — could not reach backend' })
+ } finally {
+ setSeeding(false)
+ }
+ }
+
+ return (
+
+
+
System Maintenance
+
Utilities for initializing or repairing system data.
+
+
+
+
+
+
+
Seed Default Pages
+
สร้าง CMS page layouts เริ่มต้นสำหรับทุกหน้าในระบบ
+
+
+
+
+
+
+ Run this once after a fresh install to populate default page sections for Home, Shop, Product Detail, Category, and all other layouts.
+
+
+ ⚠️ Existing pages with sections will not be modified — only empty pages will be seeded.
+
+
+
+
+
+ {seedResult && (
+
+
+ {seedResult.success
+ ?
+ :
}
+
{seedResult.message}
+
+ {seedResult.results && (
+
+ {seedResult.results.map(r => (
+
+ ●
+ {r.slug}
+ {r.status === 'created' && NEW}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/apps/admin/src/pages/SystemSettingsPage.tsx b/apps/admin/src/pages/SystemSettingsPage.tsx
index 43e7bb6..9013a83 100644
--- a/apps/admin/src/pages/SystemSettingsPage.tsx
+++ b/apps/admin/src/pages/SystemSettingsPage.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { API_BASE, type StoreSettings } from '@homaroma/types'
-import { Save, Loader2, Landmark, Truck, DatabaseZap, CheckCircle2, AlertCircle } from 'lucide-react'
+import { Save, Loader2, Landmark, Truck, DatabaseZap, CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
const defaultThemeTokens = {
brandPrimary: '#FF6B6B',
@@ -87,6 +87,11 @@ export default function SystemSettingsPage() {
const [themeTokens, setThemeTokens] = useState>(defaultThemeTokens)
const fontPreset = resolveFontPreset(themeTokens)
+ // Scroll Reveal
+ const [scrollRevealEnabled, setScrollRevealEnabled] = useState(true)
+ const [scrollRevealStyle, setScrollRevealStyle] = useState('fade-up')
+ const [scrollRevealDuration, setScrollRevealDuration] = useState('700')
+
const applyFontPreset = (preset: FontPresetKey | 'custom') => {
if (preset === 'custom') return
@@ -123,6 +128,9 @@ export default function SystemSettingsPage() {
...defaultThemeTokens,
...(data.themeTokens && typeof data.themeTokens === 'object' ? data.themeTokens : {}),
})
+ setScrollRevealEnabled(data.scrollRevealEnabled ?? true)
+ setScrollRevealStyle(data.scrollRevealStyle || 'fade-up')
+ setScrollRevealDuration((data.scrollRevealDuration ?? 700).toString())
})
.catch(console.error)
.finally(() => setLoading(false))
@@ -173,7 +181,10 @@ export default function SystemSettingsPage() {
newsletterPlaceholder,
newsletterButtonText,
footerSupportHeading,
- footerCopyrightText
+ footerCopyrightText,
+ scrollRevealEnabled,
+ scrollRevealStyle,
+ scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
})
})
if (res.ok) alert('Settings saved successfully!')
@@ -547,6 +558,91 @@ export default function SystemSettingsPage() {
+ {/* Scroll Reveal Animation */}
+
+
+
+
+
+
+
Scroll Reveal Animation
+
ควบคุม animation effect ที่แสดงเมื่อ scroll ลงมาในหน้าเว็บ
+
+
+
+ {/* Enable / Disable toggle */}
+
+
+
Enable Scroll Reveal
+
เปิดหรือปิด animation ทั้งหมดในเว็บไซต์
+
+
+
+
+ {scrollRevealEnabled && (
+ <>
+ {/* Style picker */}
+
+
+
+ {[
+ { value: 'fade-up', label: 'Fade Up', icon: '↑' },
+ { value: 'fade-in', label: 'Fade In', icon: '○' },
+ { value: 'slide-left', label: 'Slide Left', icon: '←' },
+ { value: 'zoom-in', label: 'Zoom In', icon: '⊕' },
+ { value: 'none', label: 'None', icon: '—' },
+ ].map(opt => (
+
+ ))}
+
+
+
+ {/* Duration slider */}
+
+
+
+ {scrollRevealDuration} ms
+
+
setScrollRevealDuration(e.target.value)}
+ className="w-full accent-violet-500"
+ />
+
+ เร็ว (200ms)
+ ช้า (1500ms)
+
+
+ >
+ )}
+
+