x
This commit is contained in:
1 parent
ce2f4cf0c8
commit
5a87e7f77d
21 files changed
+1083
-88
No files matched your search
+10
-1
@@ -13,6 +13,11 @@ 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 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 MenuManagerPage from './pages/MenuManagerPage'
|
||||||
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
|
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
|
||||||
import ArticlesPage from './pages/ArticlesPage'
|
import ArticlesPage from './pages/ArticlesPage'
|
||||||
@@ -61,7 +66,11 @@ function App() {
|
|||||||
<Route path="/promotions" element={<PromotionsPage />} />
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
<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={<StoreSettingsPage />} />
|
||||||
|
<Route path="/settings/payments" element={<PaymentSettingsPage />} />
|
||||||
|
<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={<MenuManagerPage />} />
|
||||||
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
|
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
|
||||||
<Route path="/articles" element={<ArticlesPage />} />
|
<Route path="/articles" element={<ArticlesPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { useState } from 'react'
|
||||||
|
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
@@ -18,10 +19,16 @@ import {
|
|||||||
Globe2,
|
Globe2,
|
||||||
Users,
|
Users,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Store,
|
||||||
|
CreditCard,
|
||||||
|
Palette,
|
||||||
|
FileText,
|
||||||
|
Wrench,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const MAIN_MENU = [
|
||||||
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
|
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
|
||||||
{ icon: Tags, label: 'Categories', to: '/categories' },
|
{ icon: Tags, label: 'Categories', to: '/categories' },
|
||||||
{ icon: Package, label: 'Products', to: '/products' },
|
{ icon: Package, label: 'Products', to: '/products' },
|
||||||
@@ -49,11 +56,21 @@ const MENU_ITEMS = [
|
|||||||
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
|
{ icon: Layers, label: 'Menu Manager', to: '/menu-manager' },
|
||||||
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
|
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
|
||||||
{ icon: MessageSquare, label: 'Contact Messages', to: '/contacts' },
|
{ 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() {
|
export default function Sidebar() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const isInSettings = location.pathname.startsWith('/settings')
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(isInSettings)
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('homaroma_admin_token')
|
localStorage.removeItem('homaroma_admin_token')
|
||||||
@@ -72,12 +89,12 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
{/* Navigation — scrollable */}
|
{/* Navigation — scrollable */}
|
||||||
<nav className="flex-1 min-h-0 overflow-y-auto px-4 py-2 space-y-1 sidebar-nav">
|
<nav className="flex-1 min-h-0 overflow-y-auto px-4 py-2 space-y-1 sidebar-nav">
|
||||||
{MENU_ITEMS.map((item) => (
|
{MAIN_MENU.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.to === '/'}
|
end={item.to === '/'}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`sidebar-link ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
|
`sidebar-link ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -86,6 +103,36 @@ export default function Sidebar() {
|
|||||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-40 transition-opacity" />
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Settings Group */}
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(v => !v)}
|
||||||
|
className={`sidebar-link sidebar-link-inactive w-full ${isInSettings ? 'text-white' : ''}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="flex-1 text-left">Settings</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${settingsOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
|
||||||
|
{SETTINGS_MENU.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/settings'}
|
||||||
|
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>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User / Bottom */}
|
{/* User / Bottom */}
|
||||||
|
|||||||
@@ -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 <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-3xl space-y-8 animate-fade-in">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">Content Settings</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Newsletter copy and footer text displayed across the website.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-8">
|
||||||
|
{/* Footer Content */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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">Footer Content</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">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-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Support & Legal Heading</label>
|
||||||
|
<input type="text" value={footerSupportHeading} onChange={e => setFooterSupportHeading(e.target.value)}
|
||||||
|
className="admin-input w-full" placeholder="Support & Legal" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Copyright Text</label>
|
||||||
|
<input type="text" value={footerCopyrightText} onChange={e => setFooterCopyrightText(e.target.value)}
|
||||||
|
className="admin-input w-full" placeholder="© 2026 HomAroma. All rights reserved." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newsletter */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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-400">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-400">Value Proposition</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-400">Input Placeholder</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-400">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 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 Content Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { 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 {
|
interface MediaItem {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -13,6 +13,8 @@ export default function MediaLibraryPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [pasteToast, setPasteToast] = useState<string | null>(null);
|
||||||
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchMedia = async () => {
|
const fetchMedia = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,13 +35,15 @@ export default function MediaLibraryPage() {
|
|||||||
fetchMedia();
|
fetchMedia();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const showToast = (message: string) => {
|
||||||
const file = e.target.files?.[0];
|
setPasteToast(message);
|
||||||
if (!file) return;
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||||
|
toastTimerRef.current = setTimeout(() => setPasteToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = useCallback(async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
const res = await fetch(`${API_BASE}/upload`, {
|
const res = await fetch(`${API_BASE}/upload`, {
|
||||||
@@ -47,16 +51,56 @@ export default function MediaLibraryPage() {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await fetchMedia(); // Refresh list
|
await fetchMedia();
|
||||||
|
showToast(`✅ Uploaded: ${file.name || 'image'}`);
|
||||||
|
} else {
|
||||||
|
showToast('❌ Upload failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload', error);
|
console.error('Failed to upload', error);
|
||||||
|
showToast('❌ Upload error');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
e.target.value = ''; // Reset input
|
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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) => {
|
const handleDelete = async (id: string | undefined, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -89,7 +133,30 @@ export default function MediaLibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto animate-fade-in flex flex-col h-[calc(100vh-4rem)]">
|
<div className="p-8 max-w-7xl mx-auto animate-fade-in flex flex-col h-[calc(100vh-4rem)]">
|
||||||
<div className="flex justify-between items-end mb-8">
|
{/* Paste Toast Notification */}
|
||||||
|
{pasteToast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '2rem',
|
||||||
|
right: '2rem',
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'rgba(15,23,42,0.95)',
|
||||||
|
border: '1px solid rgba(34,211,238,0.4)',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
padding: '0.75rem 1.25rem',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
boxShadow: '0 0 20px rgba(34,211,238,0.2)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pasteToast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||||
<ImageIcon className="w-8 h-8 text-cyan-glow" />
|
<ImageIcon className="w-8 h-8 text-cyan-glow" />
|
||||||
@@ -108,6 +175,28 @@ export default function MediaLibraryPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Paste Hint Banner */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '0.6rem 1rem',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
background: 'rgba(34,211,238,0.06)',
|
||||||
|
border: '1px dashed rgba(34,211,238,0.25)',
|
||||||
|
color: 'rgba(148,163,184,0.9)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardPaste style={{ width: '1rem', height: '1rem', color: 'rgb(34,211,238)', flexShrink: 0 }} />
|
||||||
|
<span>
|
||||||
|
<span style={{ color: 'rgb(34,211,238)', fontWeight: 700 }}>Paste</span> an image anywhere on this page (Ctrl+V / ⌘+V) to upload instantly
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-night-900 border border-white/5 rounded-2xl p-6">
|
<div className="flex-1 overflow-y-auto bg-night-900 border border-white/5 rounded-2xl p-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col justify-center items-center h-full text-slate-500 space-y-4">
|
<div className="flex flex-col justify-center items-center h-full text-slate-500 space-y-4">
|
||||||
|
|||||||
@@ -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 <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-3xl space-y-8 animate-fade-in">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">Payment & Shipping</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Configure banking details, shipping fee and loyalty points.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-8">
|
||||||
|
{/* Shipping */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-cyan-glow/10 rounded-lg"><Truck className="w-5 h-5 text-cyan-glow" /></div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Shipping Configuration</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Flat Rate Shipping Fee (THB)</label>
|
||||||
|
<input type="number" required min="0" value={shippingFee} onChange={e => setShippingFee(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm" />
|
||||||
|
<p className="text-[10px] text-slate-400">Applied uniformly to all orders during checkout.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loyalty Points */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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">Loyalty Points</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Points Earning Rate</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-bold text-slate-400">1 THB spent =</span>
|
||||||
|
<input type="number" required min="0" step="0.01" value={pointsEarnRate}
|
||||||
|
onChange={e => setPointsEarnRate(e.target.value)} className="admin-input w-32" />
|
||||||
|
<span className="text-sm font-bold text-slate-400">Points</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-400">Example: 1.0 means 100 THB → 100 points. 0.5 means 100 THB → 50 points.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banking */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-cyan-glow/10 rounded-lg"><Landmark className="w-5 h-5 text-cyan-glow" /></div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Banking Details</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-400">Bank Name</label>
|
||||||
|
<input type="text" required value={bankName} onChange={e => setBankName(e.target.value)}
|
||||||
|
className="admin-input w-full" placeholder="KASIKORNBANK (KBANK)" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Account Number</label>
|
||||||
|
<input type="text" required value={bankAccountNumber} onChange={e => setBankAccountNumber(e.target.value)}
|
||||||
|
className="admin-input w-full font-mono font-bold" placeholder="012-3-45678-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Account Holder Name</label>
|
||||||
|
<input type="text" required value={bankAccountName} onChange={e => setBankAccountName(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm" placeholder="Company Co., Ltd." />
|
||||||
|
</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 Payment Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <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-3xl space-y-8 animate-fade-in">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">Store Settings</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Brand identity, logo, theme preset and social media links.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-8">
|
||||||
|
{/* Brand Identity */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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 Identity</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Brand Name</label>
|
||||||
|
<input type="text" required value={brandName} onChange={e => setBrandName(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm" placeholder="HomAroma" />
|
||||||
|
<p className="text-[10px] text-slate-400">Used in header, footer, and email communications.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Logo URL</label>
|
||||||
|
<input type="url" value={logoUrl} onChange={e => setLogoUrl(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm" placeholder="https://example.com/logo.png" />
|
||||||
|
<p className="text-[10px] text-slate-400">If provided, replaces the brand name text in headers.</p>
|
||||||
|
{logoUrl && (
|
||||||
|
<div className="mt-2 p-2 bg-black/20 rounded-md inline-block">
|
||||||
|
<img src={logoUrl} alt="Logo Preview" className="h-8 object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Theme Preset</label>
|
||||||
|
<select value={themePreset} onChange={e => setThemePreset(e.target.value)}
|
||||||
|
className="admin-input w-full max-w-sm bg-slate-900 border-white/10">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="warm">Warm</option>
|
||||||
|
<option value="minimal">Minimal</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] text-slate-400">Starting palette before custom theme tokens are applied.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Media */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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">Social Media Links</h2>
|
||||||
|
</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-400">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-400">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-400">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>
|
||||||
|
|
||||||
|
<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 Store Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<SeedResult | null>(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 (
|
||||||
|
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">System Maintenance</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Utilities for initializing or repairing system data.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-amber-500/10 rounded-lg"><DatabaseZap className="w-5 h-5 text-amber-400" /></div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Seed Default Pages</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">สร้าง CMS page layouts เริ่มต้นสำหรับทุกหน้าในระบบ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Run this once after a fresh install to populate default page sections for Home, Shop, Product Detail, Category, and all other layouts.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-400/80 font-medium">
|
||||||
|
⚠️ Existing pages with sections will not be modified — only empty pages will be seeded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSeedPages}
|
||||||
|
disabled={seeding}
|
||||||
|
className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{seeding ? <Loader2 className="w-4 h-4 animate-spin" /> : <DatabaseZap className="w-4 h-4" />}
|
||||||
|
{seeding ? 'Running...' : 'Run Seed'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{seedResult && (
|
||||||
|
<div className={`rounded-lg p-4 text-sm space-y-3 ${seedResult.success ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-red-500/10 border border-red-500/20'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{seedResult.success
|
||||||
|
? <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
: <AlertCircle className="w-4 h-4 text-red-400 shrink-0" />}
|
||||||
|
<span className={seedResult.success ? 'text-emerald-300' : 'text-red-300'}>{seedResult.message}</span>
|
||||||
|
</div>
|
||||||
|
{seedResult.results && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{seedResult.results.map(r => (
|
||||||
|
<div key={r.slug} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className={r.status === 'created' ? 'text-emerald-400' : 'text-slate-500'}>●</span>
|
||||||
|
<span className={r.status === 'created' ? 'text-slate-200' : 'text-slate-500'}>{r.slug}</span>
|
||||||
|
{r.status === 'created' && <span className="text-emerald-400 font-bold">NEW</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
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 = {
|
const defaultThemeTokens = {
|
||||||
brandPrimary: '#FF6B6B',
|
brandPrimary: '#FF6B6B',
|
||||||
@@ -87,6 +87,11 @@ export default function SystemSettingsPage() {
|
|||||||
const [themeTokens, setThemeTokens] = useState<Record<string, string>>(defaultThemeTokens)
|
const [themeTokens, setThemeTokens] = useState<Record<string, string>>(defaultThemeTokens)
|
||||||
const fontPreset = resolveFontPreset(themeTokens)
|
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') => {
|
const applyFontPreset = (preset: FontPresetKey | 'custom') => {
|
||||||
if (preset === 'custom') return
|
if (preset === 'custom') return
|
||||||
|
|
||||||
@@ -123,6 +128,9 @@ export default function SystemSettingsPage() {
|
|||||||
...defaultThemeTokens,
|
...defaultThemeTokens,
|
||||||
...(data.themeTokens && typeof data.themeTokens === 'object' ? data.themeTokens : {}),
|
...(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)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -173,7 +181,10 @@ export default function SystemSettingsPage() {
|
|||||||
newsletterPlaceholder,
|
newsletterPlaceholder,
|
||||||
newsletterButtonText,
|
newsletterButtonText,
|
||||||
footerSupportHeading,
|
footerSupportHeading,
|
||||||
footerCopyrightText
|
footerCopyrightText,
|
||||||
|
scrollRevealEnabled,
|
||||||
|
scrollRevealStyle,
|
||||||
|
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (res.ok) alert('Settings saved successfully!')
|
if (res.ok) alert('Settings saved successfully!')
|
||||||
@@ -547,6 +558,91 @@ export default function SystemSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Reveal Animation */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-violet-500/10 rounded-lg">
|
||||||
|
<Sparkles className="w-5 h-5 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Scroll Reveal Animation</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">ควบคุม animation effect ที่แสดงเมื่อ scroll ลงมาในหน้าเว็บ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable / Disable toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Enable Scroll Reveal</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">เปิดหรือปิด animation ทั้งหมดในเว็บไซต์</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScrollRevealEnabled(v => !v)}
|
||||||
|
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${
|
||||||
|
scrollRevealEnabled ? 'bg-violet-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
scrollRevealEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scrollRevealEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Style picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Animation Style</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScrollRevealStyle(opt.value)}
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-lg border py-3 px-2 text-xs font-semibold transition ${
|
||||||
|
scrollRevealStyle === opt.value
|
||||||
|
? 'border-violet-500 bg-violet-500/20 text-violet-300'
|
||||||
|
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{opt.icon}</span>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration slider */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Animation Duration</label>
|
||||||
|
<span className="text-sm font-bold text-violet-300">{scrollRevealDuration} ms</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="200"
|
||||||
|
max="1500"
|
||||||
|
step="50"
|
||||||
|
value={scrollRevealDuration}
|
||||||
|
onChange={e => setScrollRevealDuration(e.target.value)}
|
||||||
|
className="w-full accent-violet-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>เร็ว (200ms)</span>
|
||||||
|
<span>ช้า (1500ms)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||||
|
import { Save, Loader2, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
const defaultThemeTokens = {
|
||||||
|
brandPrimary: '#FF6B6B', brandPrimaryHover: '#FF4500', brandAccent: '#FFF200',
|
||||||
|
brandWarm: '#FFA07A', brandCool: '#E0FFFF', brandSuccess: '#98FB98', brandSoft: '#D8BFD8',
|
||||||
|
surfacePage: '#FFF0F5', surfaceNavRest: '#F9F8F5', surfaceCard: '#FFFFFF',
|
||||||
|
textMain: '#333333', mutedText: '#666666',
|
||||||
|
linePrimary: '#00B900', linePrimaryHover: '#009900',
|
||||||
|
fontBody: '', fontDisplay: '',
|
||||||
|
shadowCard: '0 10px 25px rgba(0,0,0,0.05)', shadowCardHover: '0 20px 40px rgba(0,0,0,0.1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_PRESETS = {
|
||||||
|
locale: { label: 'Locale default', body: '', display: '' },
|
||||||
|
sarabun: { label: 'Sarabun', body: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif', display: 'var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif' },
|
||||||
|
kanit: { label: 'Kanit', body: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif', display: 'var(--font-kanit, "Kanit"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif' },
|
||||||
|
} as const
|
||||||
|
type FontPresetKey = keyof typeof FONT_PRESETS
|
||||||
|
|
||||||
|
function resolveFontPreset(tokens: Record<string, string>) {
|
||||||
|
const body = tokens.fontBody?.trim() || ''
|
||||||
|
const display = tokens.fontDisplay?.trim() || ''
|
||||||
|
if (!body && !display) return 'locale'
|
||||||
|
if (body === FONT_PRESETS.sarabun.body && display === FONT_PRESETS.sarabun.display) return 'sarabun'
|
||||||
|
if (body === FONT_PRESETS.kanit.body && display === FONT_PRESETS.kanit.display) return 'kanit'
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSettingsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const [themeTokens, setThemeTokens] = useState<Record<string, string>>(defaultThemeTokens)
|
||||||
|
const [scrollRevealEnabled, setScrollRevealEnabled] = useState(true)
|
||||||
|
const [scrollRevealStyle, setScrollRevealStyle] = useState('fade-up')
|
||||||
|
const [scrollRevealDuration, setScrollRevealDuration] = useState('700')
|
||||||
|
|
||||||
|
const fontPreset = resolveFontPreset(themeTokens)
|
||||||
|
|
||||||
|
const applyFontPreset = (preset: FontPresetKey | 'custom') => {
|
||||||
|
if (preset === 'custom') return
|
||||||
|
setThemeTokens(cur => ({ ...cur, fontBody: FONT_PRESETS[preset].body, fontDisplay: FONT_PRESETS[preset].display }))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/settings`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: StoreSettings) => {
|
||||||
|
setThemeTokens({ ...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))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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({
|
||||||
|
themeTokens,
|
||||||
|
scrollRevealEnabled,
|
||||||
|
scrollRevealStyle,
|
||||||
|
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) alert('Saved successfully!')
|
||||||
|
} catch { alert('Failed to save') } 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-3xl space-y-8 animate-fade-in">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold">Theme & Design</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Color tokens, typography, and scroll animation settings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-8">
|
||||||
|
{/* Theme Tokens */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 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">Theme Tokens</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Font Preset</label>
|
||||||
|
<select value={fontPreset} onChange={e => applyFontPreset(e.target.value as FontPresetKey | 'custom')}
|
||||||
|
className="admin-input w-full max-w-sm bg-slate-900 border-white/10">
|
||||||
|
{Object.entries(FONT_PRESETS).map(([key, p]) => <option key={key} value={key}>{p.label}</option>)}
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{(['brandPrimary', 'brandPrimaryHover', 'brandAccent', 'surfacePage', 'surfaceCard', 'textMain', 'mutedText', 'linePrimary', 'linePrimaryHover'] as const).map(key => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||||
|
<input type="color" value={themeTokens[key] || '#000000'}
|
||||||
|
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
|
||||||
|
className="h-12 w-full max-w-sm rounded-xl border border-white/10 bg-slate-900 p-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{(['fontBody', 'fontDisplay', 'shadowCard', 'shadowCardHover'] as const).map(key => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
||||||
|
<input type="text" value={themeTokens[key] || ''}
|
||||||
|
onChange={e => setThemeTokens(cur => ({ ...cur, [key]: e.target.value }))}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder={key.startsWith('font') ? 'Leave blank to use locale preset' : undefined} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Reveal */}
|
||||||
|
<div className="admin-card p-8 space-y-6">
|
||||||
|
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||||
|
<div className="p-2 bg-violet-500/10 rounded-lg"><Sparkles className="w-5 h-5 text-violet-400" /></div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Scroll Reveal Animation</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">ควบคุม animation effect ที่แสดงเมื่อ scroll ลงมาในหน้าเว็บ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">Enable Scroll Reveal</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">เปิดหรือปิด animation ทั้งหมดในเว็บไซต์</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setScrollRevealEnabled(v => !v)}
|
||||||
|
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${scrollRevealEnabled ? 'bg-violet-500' : 'bg-slate-600'}`}>
|
||||||
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${scrollRevealEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scrollRevealEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Animation Style</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button key={opt.value} type="button" onClick={() => setScrollRevealStyle(opt.value)}
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-lg border py-3 px-2 text-xs font-semibold transition ${scrollRevealStyle === opt.value ? 'border-violet-500 bg-violet-500/20 text-violet-300' : 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'}`}>
|
||||||
|
<span className="text-lg">{opt.icon}</span>{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Animation Duration</label>
|
||||||
|
<span className="text-sm font-bold text-violet-300">{scrollRevealDuration} ms</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="200" max="1500" step="50" value={scrollRevealDuration}
|
||||||
|
onChange={e => setScrollRevealDuration(e.target.value)} className="w-full accent-violet-500" />
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>เร็ว (200ms)</span><span>ช้า (1500ms)</span>
|
||||||
|
</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 Theme Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "StoreSettings" ADD COLUMN "scrollRevealDuration" INTEGER NOT NULL DEFAULT 700,
|
||||||
|
ADD COLUMN "scrollRevealEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "scrollRevealStyle" TEXT NOT NULL DEFAULT 'fade-up';
|
||||||
@@ -271,6 +271,9 @@ model StoreSettings {
|
|||||||
newsletterButtonText String?
|
newsletterButtonText String?
|
||||||
footerSupportHeading Json?
|
footerSupportHeading Json?
|
||||||
footerCopyrightText Json?
|
footerCopyrightText Json?
|
||||||
|
scrollRevealEnabled Boolean @default(true)
|
||||||
|
scrollRevealStyle String @default("fade-up")
|
||||||
|
scrollRevealDuration Int @default(700)
|
||||||
translations StoreSettingsTranslation[]
|
translations StoreSettingsTranslation[]
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 511 KiB |
@@ -39,12 +39,12 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
brandName: t.String(),
|
brandName: t.Optional(t.String()),
|
||||||
shippingFee: t.Number(),
|
shippingFee: t.Optional(t.Number()),
|
||||||
bankName: t.String(),
|
bankName: t.Optional(t.String()),
|
||||||
bankAccountName: t.String(),
|
bankAccountName: t.Optional(t.String()),
|
||||||
bankAccountNumber: t.String(),
|
bankAccountNumber: t.Optional(t.String()),
|
||||||
pointsEarnRate: t.Number(),
|
pointsEarnRate: t.Optional(t.Number()),
|
||||||
logoUrl: t.Optional(t.String()),
|
logoUrl: t.Optional(t.String()),
|
||||||
themePreset: t.Optional(t.String()),
|
themePreset: t.Optional(t.String()),
|
||||||
themeTokens: t.Optional(t.Any()),
|
themeTokens: t.Optional(t.Any()),
|
||||||
@@ -57,7 +57,10 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
newsletterPlaceholder: t.Optional(t.String()),
|
newsletterPlaceholder: t.Optional(t.String()),
|
||||||
newsletterButtonText: t.Optional(t.String()),
|
newsletterButtonText: t.Optional(t.String()),
|
||||||
footerSupportHeading: t.Optional(t.Any()),
|
footerSupportHeading: t.Optional(t.Any()),
|
||||||
footerCopyrightText: t.Optional(t.Any())
|
footerCopyrightText: t.Optional(t.Any()),
|
||||||
|
scrollRevealEnabled: t.Optional(t.Boolean()),
|
||||||
|
scrollRevealStyle: t.Optional(t.String()),
|
||||||
|
scrollRevealDuration: t.Optional(t.Number()),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.patch("/translations", async ({ body, set }) => {
|
.patch("/translations", async ({ body, set }) => {
|
||||||
|
|||||||
@@ -39,14 +39,12 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
|||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<img
|
<img
|
||||||
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
|
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
|
||||||
className="w-full h-full object-cover scale-[1.02]"
|
className="w-full h-full object-cover"
|
||||||
alt="Hero Background"
|
alt="Hero Background"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="ui-hero-overlay" />
|
|
||||||
<div className="ui-hero-overlay-strong" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-hero-content animate-fade-in">
|
<div className="ui-hero-content animate-fade-in">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import DynamicRichText from './DynamicRichText';
|
|||||||
import AccordionSection from './sections/AccordionSection';
|
import AccordionSection from './sections/AccordionSection';
|
||||||
import ContactInfoSection from './sections/ContactInfoSection';
|
import ContactInfoSection from './sections/ContactInfoSection';
|
||||||
import ContactFormSection from './sections/ContactFormSection';
|
import ContactFormSection from './sections/ContactFormSection';
|
||||||
|
import ScrollReveal from './ScrollReveal';
|
||||||
|
|
||||||
interface DynamicPageRendererProps {
|
interface DynamicPageRendererProps {
|
||||||
sections: PageSection[];
|
sections: PageSection[];
|
||||||
@@ -34,53 +35,58 @@ export default function DynamicPageRenderer({
|
|||||||
if (!section.isActive) return null;
|
if (!section.isActive) return null;
|
||||||
|
|
||||||
switch (section.type.toUpperCase()) {
|
switch (section.type.toUpperCase()) {
|
||||||
|
// ไม่ animate — แสดงตั้งแต่ต้น
|
||||||
case 'ANNOUNCEMENT_BAR':
|
case 'ANNOUNCEMENT_BAR':
|
||||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||||
case 'NEWSLETTER':
|
|
||||||
return <DynamicNewsletter key={section.id} payload={section.payload} />;
|
|
||||||
case 'HERO':
|
case 'HERO':
|
||||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||||
|
|
||||||
|
// animate เมื่อ scroll เข้า viewport
|
||||||
|
case 'NEWSLETTER':
|
||||||
|
return <ScrollReveal key={section.id}><DynamicNewsletter payload={section.payload} /></ScrollReveal>;
|
||||||
case 'CATEGORIES':
|
case 'CATEGORIES':
|
||||||
return (
|
return (
|
||||||
<DynamicCategories
|
<ScrollReveal key={section.id}>
|
||||||
key={section.id}
|
<DynamicCategories
|
||||||
payload={section.payload}
|
payload={section.payload}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
loading={categoriesLoading}
|
loading={categoriesLoading}
|
||||||
/>
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
case 'PRODUCTS':
|
case 'PRODUCTS':
|
||||||
return (
|
return (
|
||||||
<DynamicProducts
|
<ScrollReveal key={section.id}>
|
||||||
key={section.id}
|
<DynamicProducts
|
||||||
payload={section.payload}
|
payload={section.payload}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
case 'BANNER':
|
case 'BANNER':
|
||||||
return <DynamicBanner key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicBanner payload={section.payload} /></ScrollReveal>;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
return <DynamicText key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicText payload={section.payload} /></ScrollReveal>;
|
||||||
case 'RICH_TEXT':
|
case 'RICH_TEXT':
|
||||||
return <DynamicRichText key={section.id} section={section} />;
|
return <ScrollReveal key={section.id}><DynamicRichText section={section} /></ScrollReveal>;
|
||||||
case 'EDITORIAL_TILE':
|
case 'EDITORIAL_TILE':
|
||||||
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicEditorialTile payload={section.payload} /></ScrollReveal>;
|
||||||
case 'EDITORIAL_CAROUSEL':
|
case 'EDITORIAL_CAROUSEL':
|
||||||
return <DynamicEditorialCarousel key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicEditorialCarousel payload={section.payload} /></ScrollReveal>;
|
||||||
case 'HERO_ARTICLE':
|
case 'HERO_ARTICLE':
|
||||||
return <DynamicHeroArticle key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicHeroArticle payload={section.payload} /></ScrollReveal>;
|
||||||
case 'SCENT_NOTES_BREAKDOWN':
|
case 'SCENT_NOTES_BREAKDOWN':
|
||||||
return <DynamicScentNotesBreakdown key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicScentNotesBreakdown payload={section.payload} /></ScrollReveal>;
|
||||||
case 'QUOTE_CALLOUT':
|
case 'QUOTE_CALLOUT':
|
||||||
return <DynamicQuoteCallout key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicQuoteCallout payload={section.payload} /></ScrollReveal>;
|
||||||
case 'PRODUCT_CAROUSEL':
|
case 'PRODUCT_CAROUSEL':
|
||||||
return <DynamicProductCarousel key={section.id} payload={section.payload} />;
|
return <ScrollReveal key={section.id}><DynamicProductCarousel payload={section.payload} /></ScrollReveal>;
|
||||||
case 'ACCORDION':
|
case 'ACCORDION':
|
||||||
return <AccordionSection key={section.id} payload={section.payload as AccordionSectionPayload & { title?: string }} />;
|
return <ScrollReveal key={section.id}><AccordionSection payload={section.payload as AccordionSectionPayload & { title?: string }} /></ScrollReveal>;
|
||||||
case 'CONTACT_INFO':
|
case 'CONTACT_INFO':
|
||||||
return <ContactInfoSection key={section.id} payload={section.payload as ContactInfoSectionPayload & { title?: string }} />;
|
return <ScrollReveal key={section.id}><ContactInfoSection payload={section.payload as ContactInfoSectionPayload & { title?: string }} /></ScrollReveal>;
|
||||||
case 'CONTACT_FORM':
|
case 'CONTACT_FORM':
|
||||||
return <ContactFormSection key={section.id} payload={section.payload as ContactFormSectionPayload} />;
|
return <ScrollReveal key={section.id}><ContactFormSection payload={section.payload as ContactFormSectionPayload} /></ScrollReveal>;
|
||||||
default:
|
default:
|
||||||
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useScrollReveal } from '../hooks/useScrollReveal';
|
||||||
|
|
||||||
|
// Animation style -> initial transform mapping
|
||||||
|
const STYLE_TRANSFORMS: Record<string, string> = {
|
||||||
|
'fade-up': 'translateY(28px)',
|
||||||
|
'fade-in': 'translateY(0)',
|
||||||
|
'slide-left': 'translateX(28px)',
|
||||||
|
'zoom-in': 'scale(0.95)',
|
||||||
|
'none': 'translateY(0)',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrollRevealProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
delay?: number; // ms
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScrollReveal({ children, delay = 0, className = '' }: ScrollRevealProps) {
|
||||||
|
const { ref, isVisible } = useScrollReveal<HTMLDivElement>({
|
||||||
|
threshold: 0.08,
|
||||||
|
rootMargin: '0px 0px -40px 0px',
|
||||||
|
});
|
||||||
|
|
||||||
|
// อ่านค่าจาก CSS variables ที่ ThemeInjector inject ไว้
|
||||||
|
const root = typeof window !== 'undefined' ? document.documentElement : null
|
||||||
|
const enabled = root?.style.getPropertyValue('--scroll-reveal-enabled').trim() !== '0'
|
||||||
|
const style = root?.style.getPropertyValue('--scroll-reveal-style').trim() || 'fade-up'
|
||||||
|
const durationVar = root?.style.getPropertyValue('--scroll-reveal-duration').trim() || '700ms'
|
||||||
|
const durationMs = parseInt(durationVar) || 700
|
||||||
|
|
||||||
|
// ถ้าปิด animation ให้แสดงปกติเลย
|
||||||
|
if (!enabled || style === 'none') {
|
||||||
|
return <div className={className}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTransform = STYLE_TRANSFORMS[style] ?? STYLE_TRANSFORMS['fade-up']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
transform: isVisible ? 'translateY(0) translateX(0) scale(1)' : initialTransform,
|
||||||
|
transition: `opacity ${durationMs}ms ease-out ${delay}ms, transform ${durationMs}ms ease-out ${delay}ms`,
|
||||||
|
willChange: 'opacity, transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,29 +8,36 @@ export default function ThemeInjector() {
|
|||||||
fetch(`${API_BASE}/settings?lang=${lang}`)
|
fetch(`${API_BASE}/settings?lang=${lang}`)
|
||||||
.then(res => res.ok ? res.json() : null)
|
.then(res => res.ok ? res.json() : null)
|
||||||
.then(settings => {
|
.then(settings => {
|
||||||
const tokens = settings?.themeTokens
|
if (!settings) return
|
||||||
if (!tokens || typeof tokens !== 'object') return
|
|
||||||
|
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
|
||||||
// Map tokens from the backend (which use brandPrimary etc.)
|
|
||||||
// to the CSS variables expected by SPA's tailwind.config.js
|
|
||||||
const mapping: Record<string, string> = {
|
|
||||||
'--color-pop-pink': tokens.brandPrimary,
|
|
||||||
'--color-pop-yellow': tokens.brandAccent,
|
|
||||||
'--color-pop-orange': tokens.brandWarm,
|
|
||||||
'--color-pop-cyan': tokens.brandCool,
|
|
||||||
'--color-pop-bg': tokens.surfacePage,
|
|
||||||
'--color-pop-black': tokens.textMain,
|
|
||||||
|
|
||||||
'--color-night-900': tokens.surfaceCard,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(mapping)) {
|
// --- Theme tokens ---
|
||||||
if (value && typeof value === 'string' && value.trim() !== '') {
|
const tokens = settings?.themeTokens
|
||||||
root.style.setProperty(key, value)
|
if (tokens && typeof tokens === 'object') {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
'--color-pop-pink': tokens.brandPrimary,
|
||||||
|
'--color-pop-yellow': tokens.brandAccent,
|
||||||
|
'--color-pop-orange': tokens.brandWarm,
|
||||||
|
'--color-pop-cyan': tokens.brandCool,
|
||||||
|
'--color-pop-bg': tokens.surfacePage,
|
||||||
|
'--color-pop-black': tokens.textMain,
|
||||||
|
'--color-night-900': tokens.surfaceCard,
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(mapping)) {
|
||||||
|
if (value && typeof value === 'string' && value.trim() !== '') {
|
||||||
|
root.style.setProperty(key, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Scroll reveal settings ---
|
||||||
|
const enabled = settings.scrollRevealEnabled ?? true
|
||||||
|
const style = settings.scrollRevealStyle ?? 'fade-up'
|
||||||
|
const duration = settings.scrollRevealDuration ?? 700
|
||||||
|
|
||||||
|
root.style.setProperty('--scroll-reveal-enabled', enabled ? '1' : '0')
|
||||||
|
root.style.setProperty('--scroll-reveal-style', style)
|
||||||
|
root.style.setProperty('--scroll-reveal-duration', `${duration}ms`)
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Failed to inject theme in SPA:', err))
|
.catch(err => console.error('Failed to inject theme in SPA:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface UseScrollRevealOptions {
|
||||||
|
threshold?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
once?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollReveal<T extends HTMLElement = HTMLDivElement>(
|
||||||
|
options: UseScrollRevealOptions = {}
|
||||||
|
) {
|
||||||
|
const { threshold = 0.1, rootMargin = '0px 0px -60px 0px', once = true } = options;
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
if (once) observer.disconnect();
|
||||||
|
} else if (!once) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold, rootMargin }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [threshold, rootMargin, once]);
|
||||||
|
|
||||||
|
return { ref, isVisible };
|
||||||
|
}
|
||||||
@@ -134,22 +134,6 @@
|
|||||||
@apply relative flex min-h-[90vh] items-center justify-center overflow-hidden bg-night-900 text-pop-bg md:aspect-[21/9] md:min-h-0;
|
@apply relative flex min-h-[90vh] items-center justify-center overflow-hidden bg-night-900 text-pop-bg md:aspect-[21/9] md:min-h-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: color-mix(in srgb, var(--color-pop-bg) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-hero-overlay-strong {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to top,
|
|
||||||
color-mix(in srgb, var(--color-pop-bg) 90%, transparent),
|
|
||||||
color-mix(in srgb, var(--color-pop-bg) 10%, transparent),
|
|
||||||
color-mix(in srgb, var(--color-pop-bg) 30%, transparent)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-hero-content {
|
.ui-hero-content {
|
||||||
@apply relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0;
|
@apply relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0;
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export interface StoreSettings {
|
|||||||
newsletterButtonText?: string | null
|
newsletterButtonText?: string | null
|
||||||
footerSupportHeading?: any | null
|
footerSupportHeading?: any | null
|
||||||
footerCopyrightText?: any | null
|
footerCopyrightText?: any | null
|
||||||
|
scrollRevealEnabled?: boolean | null
|
||||||
|
scrollRevealStyle?: string | null
|
||||||
|
scrollRevealDuration?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
|
|||||||
Reference in new issue
Block a user