This commit is contained in:
Your Name committed 2026-06-29 19:24:57 -04:00
1 parent ce2f4cf0c8
commit 5a87e7f77d
21 files changed
+1082 -87

No files matched your search

+10 -1
View File
@@ -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() {
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<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="/subscribers" element={<NewsletterSubscribersPage />} />
<Route path="/articles" element={<ArticlesPage />} />
+52 -5
View File
@@ -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,7 +89,7 @@ export default function Sidebar() {
{/* Navigation — scrollable */}
<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
key={item.to}
to={item.to}
@@ -86,6 +103,36 @@ export default function Sidebar() {
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-40 transition-opacity" />
</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>
{/* 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>
)
}
+98 -9
View File
@@ -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<string | null>(null);
const [pasteToast, setPasteToast] = useState<string | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchMedia = async () => {
try {
@@ -33,13 +35,15 @@ export default function MediaLibraryPage() {
fetchMedia();
}, []);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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<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) => {
e.stopPropagation();
if (!id) return;
@@ -89,7 +133,30 @@ export default function MediaLibraryPage() {
return (
<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>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<ImageIcon className="w-8 h-8 text-cyan-glow" />
@@ -108,6 +175,28 @@ export default function MediaLibraryPage() {
</label>
</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">
{loading ? (
<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>
)
}
+128
View File
@@ -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>
)
}
+98 -2
View File
@@ -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<Record<string, string>>(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() {
</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
type="submit"
disabled={saving}
+192
View File
@@ -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';
+3
View File
@@ -271,6 +271,9 @@ model StoreSettings {
newsletterButtonText String?
footerSupportHeading Json?
footerCopyrightText Json?
scrollRevealEnabled Boolean @default(true)
scrollRevealStyle String @default("fade-up")
scrollRevealDuration Int @default(700)
translations StoreSettingsTranslation[]
updatedAt DateTime @updatedAt
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

+10 -7
View File
@@ -39,12 +39,12 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
}
}, {
body: t.Object({
brandName: t.String(),
shippingFee: t.Number(),
bankName: t.String(),
bankAccountName: t.String(),
bankAccountNumber: t.String(),
pointsEarnRate: t.Number(),
brandName: t.Optional(t.String()),
shippingFee: t.Optional(t.Number()),
bankName: t.Optional(t.String()),
bankAccountName: t.Optional(t.String()),
bankAccountNumber: t.Optional(t.String()),
pointsEarnRate: t.Optional(t.Number()),
logoUrl: t.Optional(t.String()),
themePreset: t.Optional(t.String()),
themeTokens: t.Optional(t.Any()),
@@ -57,7 +57,10 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
newsletterPlaceholder: t.Optional(t.String()),
newsletterButtonText: t.Optional(t.String()),
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 }) => {
+1 -3
View File
@@ -39,14 +39,12 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
<div className="absolute inset-0 z-0">
<img
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"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className="ui-hero-overlay" />
<div className="ui-hero-overlay-strong" />
</div>
<div className="ui-hero-content animate-fade-in">
+31 -25
View File
@@ -16,6 +16,7 @@ import DynamicRichText from './DynamicRichText';
import AccordionSection from './sections/AccordionSection';
import ContactInfoSection from './sections/ContactInfoSection';
import ContactFormSection from './sections/ContactFormSection';
import ScrollReveal from './ScrollReveal';
interface DynamicPageRendererProps {
sections: PageSection[];
@@ -34,53 +35,58 @@ export default function DynamicPageRenderer({
if (!section.isActive) return null;
switch (section.type.toUpperCase()) {
// ไม่ animate — แสดงตั้งแต่ต้น
case 'ANNOUNCEMENT_BAR':
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
case 'NEWSLETTER':
return <DynamicNewsletter key={section.id} payload={section.payload} />;
case 'HERO':
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':
return (
<DynamicCategories
key={section.id}
payload={section.payload}
categories={categories}
loading={categoriesLoading}
/>
<ScrollReveal key={section.id}>
<DynamicCategories
payload={section.payload}
categories={categories}
loading={categoriesLoading}
/>
</ScrollReveal>
);
case 'PRODUCTS':
return (
<DynamicProducts
key={section.id}
payload={section.payload}
categories={categories}
/>
<ScrollReveal key={section.id}>
<DynamicProducts
payload={section.payload}
categories={categories}
/>
</ScrollReveal>
);
case 'BANNER':
return <DynamicBanner key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicBanner payload={section.payload} /></ScrollReveal>;
case 'TEXT':
return <DynamicText key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicText payload={section.payload} /></ScrollReveal>;
case 'RICH_TEXT':
return <DynamicRichText key={section.id} section={section} />;
return <ScrollReveal key={section.id}><DynamicRichText section={section} /></ScrollReveal>;
case 'EDITORIAL_TILE':
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicEditorialTile payload={section.payload} /></ScrollReveal>;
case 'EDITORIAL_CAROUSEL':
return <DynamicEditorialCarousel key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicEditorialCarousel payload={section.payload} /></ScrollReveal>;
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':
return <DynamicScentNotesBreakdown key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicScentNotesBreakdown payload={section.payload} /></ScrollReveal>;
case 'QUOTE_CALLOUT':
return <DynamicQuoteCallout key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicQuoteCallout payload={section.payload} /></ScrollReveal>;
case 'PRODUCT_CAROUSEL':
return <DynamicProductCarousel key={section.id} payload={section.payload} />;
return <ScrollReveal key={section.id}><DynamicProductCarousel payload={section.payload} /></ScrollReveal>;
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':
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':
return <ContactFormSection key={section.id} payload={section.payload as ContactFormSectionPayload} />;
return <ScrollReveal key={section.id}><ContactFormSection payload={section.payload as ContactFormSectionPayload} /></ScrollReveal>;
default:
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
return null;
+52
View File
@@ -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>
);
}
+26 -19
View File
@@ -8,29 +8,36 @@ export default function ThemeInjector() {
fetch(`${API_BASE}/settings?lang=${lang}`)
.then(res => res.ok ? res.json() : null)
.then(settings => {
const tokens = settings?.themeTokens
if (!tokens || typeof tokens !== 'object') return
if (!settings) return
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)) {
if (value && typeof value === 'string' && value.trim() !== '') {
root.style.setProperty(key, value)
// --- Theme tokens ---
const tokens = settings?.themeTokens
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))
}, [])
+37
View File
@@ -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 };
}
-16
View File
@@ -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;
}
.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 {
@apply relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0;
+3
View File
@@ -190,6 +190,9 @@ export interface StoreSettings {
newsletterButtonText?: string | null
footerSupportHeading?: any | null
footerCopyrightText?: any | null
scrollRevealEnabled?: boolean | null
scrollRevealStyle?: string | null
scrollRevealDuration?: number | null
}
export interface MenuItem {