x
This commit is contained in:
1 parent
5e724fae20
commit
4014e4d97b
18 files changed
+236
-60
No files matched your search
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomAroma Admin</title>
|
||||
<title>Admin Control Center</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,600&display=swap" rel="stylesheet" />
|
||||
|
||||
@@ -71,13 +71,15 @@ import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
||||
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||
import PosPage from './pages/PosPage'
|
||||
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Route */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<StoreSettingsProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Route */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Guarded Admin Routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
@@ -171,6 +173,7 @@ function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</StoreSettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Outlet, Link } from 'react-router-dom'
|
||||
import { Store } from 'lucide-react'
|
||||
import Sidebar from './Sidebar'
|
||||
import LanguageSelector from './LanguageSelector'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { brandName } = useStoreSettings()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-night-950">
|
||||
@@ -16,7 +18,7 @@ export default function AdminLayout() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-xs text-slate-400 font-medium hidden sm:inline">HomAroma Control Center</span>
|
||||
<span className="text-xs text-slate-400 font-medium hidden sm:inline">{brandName} Control Center</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { getAdminToken } from '../utils/auth'
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const token = localStorage.getItem('homaroma_admin_token')
|
||||
const token = getAdminToken()
|
||||
|
||||
if (!token) {
|
||||
// If no token, redirect to login page
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
Map,
|
||||
Target,
|
||||
} from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
import { clearAdminAuth } from '../utils/auth'
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean
|
||||
@@ -45,6 +47,7 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
const { brandName } = useStoreSettings()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -174,8 +177,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('homaroma_admin_token')
|
||||
localStorage.removeItem('homaroma_admin_user')
|
||||
clearAdminAuth()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
<div className={`p-6 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} border-b border-white/5`}>
|
||||
{!isCollapsed && (
|
||||
<div className="text-xl font-bold tracking-widest text-white uppercase italic truncate">
|
||||
HomAroma <span className="text-cyan-glow text-xs not-italic font-light opacity-60">Admin</span>
|
||||
{brandName} <span className="text-cyan-glow text-xs not-italic font-light opacity-60">Admin</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag, ExternalLink } from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht } from '../../utils/thaiGeoUtils';
|
||||
import { useStoreSettings } from '../../contexts/StoreSettingsContext';
|
||||
import { getAuthHeaders } from '../../utils/auth';
|
||||
|
||||
interface OrderItem {
|
||||
name: string;
|
||||
@@ -37,6 +39,7 @@ interface RepeatCustomerOrdersModalProps {
|
||||
}
|
||||
|
||||
export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatCustomerOrdersModalProps) {
|
||||
const { brandName } = useStoreSettings();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState<OrderHistory[]>([]);
|
||||
const [customerDetails, setCustomerDetails] = useState<any>(null);
|
||||
@@ -48,10 +51,7 @@ export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatC
|
||||
params.set('identifier', customer.identifier);
|
||||
|
||||
fetch(`${API_BASE}/business-analytics/repeat-customers/detail?${params.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
@@ -256,7 +256,7 @@ export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatC
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-white/10 bg-white/[0.02] flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
คำนวณจากประวัติคำสั่งซื้อในระบบ HomAroma
|
||||
{`คำนวณจากประวัติคำสั่งซื้อในระบบ ${brandName}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { API_BASE, type StoreSettings } from '@homaroma/types';
|
||||
|
||||
interface StoreSettingsContextType {
|
||||
settings: StoreSettings | null;
|
||||
brandName: string;
|
||||
logoUrl: string | null;
|
||||
isLoading: boolean;
|
||||
refetchSettings: () => Promise<void>;
|
||||
updateSettingsState: (partial: Partial<StoreSettings>) => void;
|
||||
}
|
||||
|
||||
const StoreSettingsContext = createContext<StoreSettingsContextType>({
|
||||
settings: null,
|
||||
brandName: 'HomAroma',
|
||||
logoUrl: null,
|
||||
isLoading: true,
|
||||
refetchSettings: async () => {},
|
||||
updateSettingsState: () => {},
|
||||
});
|
||||
|
||||
export const StoreSettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/settings`);
|
||||
if (res.ok) {
|
||||
const data: StoreSettings = await res.json();
|
||||
setSettings(data);
|
||||
if (data.brandName) {
|
||||
document.title = `${data.brandName} Admin`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[StoreSettingsContext] Failed to load settings:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const updateSettingsState = useCallback((partial: Partial<StoreSettings>) => {
|
||||
setSettings(prev => {
|
||||
if (!prev) return prev;
|
||||
const updated = { ...prev, ...partial };
|
||||
if (updated.brandName) {
|
||||
document.title = `${updated.brandName} Admin`;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const brandName = settings?.brandName || 'HomAroma';
|
||||
const logoUrl = settings?.logoUrl || null;
|
||||
|
||||
return (
|
||||
<StoreSettingsContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
brandName,
|
||||
logoUrl,
|
||||
isLoading,
|
||||
refetchSettings: fetchSettings,
|
||||
updateSettingsState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StoreSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStoreSettings = () => useContext(StoreSettingsContext);
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||
import { Save, Loader2 } from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
|
||||
export default function ContentSettingsPage() {
|
||||
const { brandName } = useStoreSettings()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -79,7 +81,7 @@ export default function ContentSettingsPage() {
|
||||
<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." />
|
||||
className="admin-input w-full" placeholder={`© ${new Date().getFullYear()} ${brandName || 'Store'}. All rights reserved.`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Package, Tag, Layers, TrendingUp } from 'lucide-react'
|
||||
import { API_BASE, type Category, type Product } from '@homaroma/types'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { brandName } = useStoreSettings()
|
||||
const { t } = useTranslation()
|
||||
const [stats, setStats] = useState({
|
||||
products: 0,
|
||||
@@ -74,7 +76,7 @@ export default function DashboardPage() {
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">{t('dashboard.title', 'System Overview')}</h1>
|
||||
<p className="text-slate-500 text-sm">{t('dashboard.subtitle', 'Real-time stats from the HomAroma platform.')}</p>
|
||||
<p className="text-slate-500 text-sm">{t('dashboard.subtitle', `Real-time stats from the ${brandName} platform.`)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs uppercase tracking-widest text-slate-500">Last updated</p>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Lock, User, AlertCircle, ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
import LanguageSelector from '../components/LanguageSelector'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
import { setAdminToken, setAdminUser } from '../utils/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { brandName } = useStoreSettings()
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -32,8 +35,8 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('homaroma_admin_token', data.token)
|
||||
localStorage.setItem('homaroma_admin_user', JSON.stringify(data.user))
|
||||
setAdminToken(data.token)
|
||||
setAdminUser(data.user)
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
@@ -61,7 +64,7 @@ export default function LoginPage() {
|
||||
Authorized Access Only
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white">
|
||||
HomAroma <span className="text-cyan-glow">{t('login.title', 'Admin')}</span>
|
||||
{brandName} <span className="text-cyan-glow">{t('login.title', 'Admin')}</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm">{t('login.subtitle', 'Welcome back to the sensory command center.')}</p>
|
||||
</div>
|
||||
@@ -127,7 +130,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-slate-600 uppercase tracking-widest">
|
||||
© 2026 HomAroma & Essential Oils
|
||||
© {new Date().getFullYear()} {brandName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
||||
import { getAdminDisplayString } from '../utils/localization';
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal';
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext';
|
||||
import { getAuthHeaders } from '../utils/auth';
|
||||
|
||||
import HeroForm from '../components/page-builder/HeroForm';
|
||||
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
|
||||
@@ -83,6 +85,7 @@ interface Brand {
|
||||
}
|
||||
|
||||
export default function PageBuilderPage() {
|
||||
const { brandName } = useStoreSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const frontendBase = (
|
||||
@@ -90,10 +93,7 @@ export default function PageBuilderPage() {
|
||||
? window.location.origin
|
||||
: (import.meta.env.VITE_FRONTEND_URL || window.location.origin)
|
||||
).replace(/\/$/, '');
|
||||
const authHeaders = () => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
});
|
||||
const authHeaders = () => getAuthHeaders();
|
||||
const [page, setPage] = useState<Page | null>(null);
|
||||
const [sections, setSections] = useState<PageSection[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
@@ -606,7 +606,7 @@ export default function PageBuilderPage() {
|
||||
case 'EDITORIAL_TILE':
|
||||
defaultPayload = {
|
||||
title: 'Crafted with [Sensory Passion]',
|
||||
description: 'Every formulation at HomAroma is a tribute to pure botanical essences, hand-blended to capture nature’s quiet intelligence.',
|
||||
description: `Every formulation at ${brandName} is a tribute to pure botanical essences, hand-blended to capture nature’s quiet intelligence.`,
|
||||
imageUrl: '/images/oil_ozone.png',
|
||||
imagePosition: 'left',
|
||||
buttonText: 'Read Our Story',
|
||||
@@ -791,7 +791,7 @@ export default function PageBuilderPage() {
|
||||
defaultPayload = {
|
||||
title: 'Scent Guide [Article]',
|
||||
subtitle: 'Discover the story behind each fragrance.',
|
||||
author: 'HomAroma',
|
||||
author: brandName || 'Author',
|
||||
category: 'Story',
|
||||
publishDate: '2026-01-01',
|
||||
imageUrl: '/images/hero.png',
|
||||
@@ -860,7 +860,7 @@ export default function PageBuilderPage() {
|
||||
case 'QUOTE_CALLOUT':
|
||||
defaultPayload = {
|
||||
quote: 'Fragrance is the invisible, unforgettable accessory.',
|
||||
author: 'HomAroma',
|
||||
author: brandName || 'Author',
|
||||
role: 'Founder',
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, formatPrice, type Product, type Category, type Order } from '@homaroma/types'
|
||||
import { getAdminDisplayString } from '../utils/localization'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
import { getAdminUser } from '../utils/auth'
|
||||
|
||||
interface PosCartItem {
|
||||
productId: number
|
||||
@@ -88,9 +90,11 @@ export default function PosPage() {
|
||||
const [completedOrder, setCompletedOrder] = useState<Order | null>(null)
|
||||
const [receiptModalOpen, setReceiptModalOpen] = useState(false)
|
||||
|
||||
const { brandName: contextBrandName, settings: contextSettings } = useStoreSettings()
|
||||
|
||||
// Store Brand Settings
|
||||
const [storeName, setStoreName] = useState('HomAroma Store')
|
||||
const [pointsEarnRate, setPointsEarnRate] = useState(1.0)
|
||||
const [storeName, setStoreName] = useState(contextBrandName ? `${contextBrandName} Store` : 'Store')
|
||||
const [pointsEarnRate, setPointsEarnRate] = useState(contextSettings?.pointsEarnRate ? Number(contextSettings.pointsEarnRate) : 1.0)
|
||||
|
||||
// Fullscreen State
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
@@ -102,17 +106,17 @@ export default function PosPage() {
|
||||
useEffect(() => {
|
||||
// Current cashier
|
||||
try {
|
||||
const userStr = localStorage.getItem('homaroma_admin_user')
|
||||
if (userStr) setCashier(JSON.parse(userStr))
|
||||
const user = getAdminUser()
|
||||
if (user) setCashier(user)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
// Load store settings
|
||||
// Load store settings fallback/sync
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.brandName) setStoreName(data.brandName)
|
||||
if (data.brandName) setStoreName(`${data.brandName} Store`)
|
||||
if (data.pointsEarnRate) setPointsEarnRate(Number(data.pointsEarnRate))
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||
import { Save, Loader2 } from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
|
||||
export default function StoreSettingsPage() {
|
||||
const { updateSettingsState } = useStoreSettings()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -17,7 +19,7 @@ export default function StoreSettingsPage() {
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(r => r.json())
|
||||
.then((data: StoreSettings) => {
|
||||
setBrandName(data.brandName || 'HomAroma')
|
||||
setBrandName(data.brandName || '')
|
||||
setLogoUrl(data.logoUrl || '')
|
||||
setThemePreset(data.themePreset || 'default')
|
||||
setFacebookUrl(data.facebookUrl || '')
|
||||
@@ -37,7 +39,10 @@ export default function StoreSettingsPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl }),
|
||||
})
|
||||
if (res.ok) alert('Saved successfully!')
|
||||
if (res.ok) {
|
||||
updateSettingsState({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl })
|
||||
alert('Saved successfully!')
|
||||
}
|
||||
} catch { alert('Failed to save') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
@@ -62,7 +67,7 @@ export default function StoreSettingsPage() {
|
||||
<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" />
|
||||
className="admin-input w-full max-w-sm" placeholder="Enter Brand Name" />
|
||||
<p className="text-[10px] text-slate-400">Used in header, footer, and email communications.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||
import { Save, Loader2, Landmark, Truck, DatabaseZap, CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
|
||||
const defaultThemeTokens = {
|
||||
brandPrimary: '#FF6B6B',
|
||||
@@ -113,11 +114,13 @@ export default function SystemSettingsPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
const { updateSettingsState } = useStoreSettings()
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(r => r.json())
|
||||
.then((data: StoreSettings) => {
|
||||
setBrandName(data.brandName || 'HomAroma')
|
||||
setBrandName(data.brandName || '')
|
||||
setLogoUrl(data.logoUrl || '')
|
||||
setThemePreset(data.themePreset || 'default')
|
||||
setShippingFee(data.shippingFee.toString())
|
||||
@@ -198,7 +201,33 @@ export default function SystemSettingsPage() {
|
||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||
})
|
||||
})
|
||||
if (res.ok) alert('Settings saved successfully!')
|
||||
if (res.ok) {
|
||||
updateSettingsState({
|
||||
brandName,
|
||||
logoUrl,
|
||||
themePreset,
|
||||
themeTokens,
|
||||
shippingFee: parseFloat(shippingFee) || 0,
|
||||
bankName,
|
||||
bankAccountName,
|
||||
bankAccountNumber,
|
||||
pointsEarnRate: parseFloat(pointsEarnRate) || 1,
|
||||
footerDescription,
|
||||
facebookUrl,
|
||||
instagramUrl,
|
||||
tiktokUrl,
|
||||
newsletterTitle,
|
||||
newsletterDescription,
|
||||
newsletterPlaceholder,
|
||||
newsletterButtonText,
|
||||
footerSupportHeading,
|
||||
footerCopyrightText,
|
||||
scrollRevealEnabled,
|
||||
scrollRevealStyle,
|
||||
scrollRevealDuration: parseInt(scrollRevealDuration) || 700,
|
||||
})
|
||||
alert('Settings saved successfully!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Failed to save settings')
|
||||
@@ -339,7 +368,7 @@ export default function SystemSettingsPage() {
|
||||
value={brandName}
|
||||
onChange={e => setBrandName(e.target.value)}
|
||||
className="admin-input w-full max-w-sm"
|
||||
placeholder="HomAroma"
|
||||
placeholder="Enter Brand Name"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">This name is used by the website and spa header/footer as the brand display name.</p>
|
||||
</div>
|
||||
@@ -436,7 +465,7 @@ export default function SystemSettingsPage() {
|
||||
value={footerCopyrightText}
|
||||
onChange={e => setFooterCopyrightText(e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="© 2026 HomAroma. All rights reserved."
|
||||
placeholder={`© ${new Date().getFullYear()} ${brandName || 'Store'}. All rights reserved.`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
|
||||
import { useStoreSettings } from '../../contexts/StoreSettingsContext';
|
||||
import { getAuthHeaders } from '../../utils/auth';
|
||||
|
||||
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||
type PriorityFilter = 'all' | 'due_soon' | 'overdue' | 'vip' | 'healthy';
|
||||
@@ -45,6 +47,7 @@ interface ActionPlanCustomer {
|
||||
}
|
||||
|
||||
export default function RepeatCustomersActionPlanPage() {
|
||||
const { brandName } = useStoreSettings();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -97,10 +100,7 @@ export default function RepeatCustomersActionPlanPage() {
|
||||
|
||||
fetch(`${API_BASE}/crm/check-imported`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ keys }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
@@ -385,22 +385,22 @@ export default function RepeatCustomersActionPlanPage() {
|
||||
// Generate Message Template for Copying
|
||||
const getCustomerMessageTemplate = (cust: ActionPlanCustomer) => {
|
||||
const action = getActionRecommendation(cust);
|
||||
const prodName = cust.lastOrderProduct || 'น้ำหอมปรับอากาศ/น้ำมันหอมระเหย HomAroma';
|
||||
const prodName = cust.lastOrderProduct || `สินค้า ${brandName}`;
|
||||
const cleanName = cust.displayName.replace(/\s*\(\d+\)$/, '');
|
||||
|
||||
if (action.strategyKey === 'vip') {
|
||||
return `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ HomAroma เสมอมาครับ\n\nวันนี้ทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณโดยเฉพาะ! รับส่วนลด 15% หรือสิทธิ์รับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อเติม ${prodName} รอบถัดไปนะครับ 🌿\n\nแจ้งโค้ด: [${action.promoCode}] กับแอดมินได้เลยครับ 😊`;
|
||||
return `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ ${brandName} เสมอมาครับ\n\nวันนี้ทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณโดยเฉพาะ! รับส่วนลด 15% หรือสิทธิ์รับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อเติม ${prodName} รอบถัดไปนะครับ 🌿\n\nแจ้งโค้ด: [${action.promoCode}] กับแอดมินได้เลยครับ 😊`;
|
||||
}
|
||||
|
||||
if (action.strategyKey === 'due_soon') {
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌿 จาก HomAroma นะครับ\n\n${prodName} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ ลดเพิ่ม 10% [${action.promoCode}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨\n\nสนใจเติมสินค้ากลิ่นเดิมหรือลองกลิ่นใหม่ ทักข้อความหาเราได้เลยนะครับ!`;
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌿 จาก ${brandName} นะครับ\n\n${prodName} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ ลดเพิ่ม 10% [${action.promoCode}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨\n\nสนใจเติมสินค้ากลิ่นเดิมหรือลองกลิ่นใหม่ ทักข้อความหาเราได้เลยนะครับ!`;
|
||||
}
|
||||
|
||||
if (action.strategyKey === 'overdue') {
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌸 HomAroma คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back สุดคุ้ม ลดพิเศษ 20% ทันที [${action.promoCode}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${prodName} หรือกลิ่นใหม่ยอดฮิต 🌿\n\nโค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌸 ${brandName} คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back สุดคุ้ม ลดพิเศษ 20% ทันที [${action.promoCode}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${prodName} หรือกลิ่นใหม่ยอดฮิต 🌿\n\nโค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
|
||||
}
|
||||
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌿 HomAroma ขอขอบพระคุณสำหรับคำสั่งซื้อที่ผ่านมานะครับ หากคุณลูกค้าต้องการคำแนะนำกลิ่นหอมเพิ่มเติมหรือการใช้งานเครื่องพ่น ทักสอบถามเราได้ตลอดเลยนะครับ ✨`;
|
||||
return `สวัสดีครับคุณ ${cleanName} 🌿 ${brandName} ขอขอบพระคุณสำหรับคำสั่งซื้อที่ผ่านมานะครับ หากคุณลูกค้าต้องการคำแนะนำกลิ่นหอมเพิ่มเติมหรือการใช้งานเครื่องพ่น ทักสอบถามเราได้ตลอดเลยนะครับ ✨`;
|
||||
};
|
||||
|
||||
const handleCopyTemplate = (cust: ActionPlanCustomer) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht } from '../../utils/thaiGeoUtils';
|
||||
import { useStoreSettings } from '../../contexts/StoreSettingsContext';
|
||||
|
||||
interface OrderItem {
|
||||
name: string;
|
||||
@@ -37,6 +38,7 @@ interface OrderHistory {
|
||||
}
|
||||
|
||||
export default function MarketplaceCustomerDetailPage() {
|
||||
const { brandName } = useStoreSettings();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -155,16 +157,17 @@ export default function MarketplaceCustomerDetailPage() {
|
||||
const handleCopyChatTemplate = () => {
|
||||
if (!customer) return;
|
||||
const cleanName = customer.displayName.replace(/\s*\(\d+\)$/, '');
|
||||
const promo = promoCodeSent || 'HOMAROMA';
|
||||
const lastProd = orders?.[0]?.items?.[0]?.name || 'น้ำหอมปรับอากาศ/น้ำมันหอมระเหย HomAroma';
|
||||
const defaultCode = (brandName || 'VIP').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
const promo = promoCodeSent || defaultCode;
|
||||
const lastProd = orders?.[0]?.items?.[0]?.name || `สินค้า ${brandName}`;
|
||||
|
||||
let msg = '';
|
||||
if (strategyType === 'vip' || strategyType === 'vip_reward') {
|
||||
msg = `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ HomAroma เสมอมาครับ\n\nทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณ รับส่วนลด 15% หรือรับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อ ${lastProd} นะครับ 🌿\n\nแจ้งโค้ด: [${promo}] กับแอดมินได้เลยครับ 😊`;
|
||||
msg = `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ ${brandName} เสมอมาครับ\n\nทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณ รับส่วนลด 15% หรือรับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อ ${lastProd} นะครับ 🌿\n\nแจ้งโค้ด: [${promo}] กับแอดมินได้เลยครับ 😊`;
|
||||
} else if (strategyType === 'win_back' || strategyType === 'overdue') {
|
||||
msg = `สวัสดีครับคุณ ${cleanName} 🌸 HomAroma คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back ลดพิเศษ 20% [${promo}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${lastProd} หรือกลิ่นใหม่ยอดฮิต 🌿 โค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
|
||||
msg = `สวัสดีครับคุณ ${cleanName} 🌸 ${brandName} คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back ลดพิเศษ 20% [${promo}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${lastProd} หรือกลิ่นใหม่ยอดฮิต 🌿 โค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
|
||||
} else {
|
||||
msg = `สวัสดีครับคุณ ${cleanName} 🌿 จาก HomAroma นะครับ\n\n${lastProd} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ [${promo}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨ สนใจเติมสินค้าทักหาเราได้เลยนะครับ!`;
|
||||
msg = `สวัสดีครับคุณ ${cleanName} 🌿 จาก ${brandName} นะครับ\n\n${lastProd} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ [${promo}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨ สนใจเติมสินค้าทักหาเราได้เลยนะครับ!`;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(msg);
|
||||
|
||||
@@ -2,10 +2,13 @@ import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { API_BASE, type FooterBrandResponse } from '@homaroma/types'
|
||||
import { AlertCircle, CheckCircle2, Image as ImageIcon, Link as LinkIcon, Loader2, Save } from 'lucide-react'
|
||||
import { useStoreSettings } from '../../contexts/StoreSettingsContext'
|
||||
import { getAuthHeaders } from '../../utils/auth'
|
||||
|
||||
type SaveState = { type: 'success' | 'error'; message: string } | null
|
||||
|
||||
export default function FooterBrandPage() {
|
||||
const { updateSettingsState } = useStoreSettings()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveState, setSaveState] = useState<SaveState>(null)
|
||||
@@ -51,13 +54,13 @@ export default function FooterBrandPage() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/settings/footer-brand`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token') || ''}`,
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ brandName, logoUrl: logoUrl || null, description, facebookUrl: facebookUrl || null, instagramUrl: instagramUrl || null, tiktokUrl: tiktokUrl || null, expectedVersion: version }),
|
||||
})
|
||||
const result = await response.json() as FooterBrandResponse & { error?: string; code?: string }
|
||||
if (response.ok && result.brand) {
|
||||
updateSettingsState({ brandName, logoUrl })
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (response.status === 409 && result.code === 'VERSION_CONFLICT') {
|
||||
throw new Error('Footer Brand changed in another session. Reload the page before saving again.')
|
||||
@@ -97,7 +100,7 @@ export default function FooterBrandPage() {
|
||||
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand identity</h2><p className="text-xs text-slate-500">Used by the footer and shared site brand.</p></div>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-brand-name" className="text-xs font-bold uppercase tracking-widest text-slate-400">Brand name</label><input id="footer-brand-name" required value={brandName} onChange={event => setBrandName(event.target.value)} className="admin-input w-full" placeholder="HomAroma" /></div>
|
||||
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-brand-name" className="text-xs font-bold uppercase tracking-widest text-slate-400">Brand name</label><input id="footer-brand-name" required value={brandName} onChange={event => setBrandName(event.target.value)} className="admin-input w-full" placeholder="Enter Brand Name" /></div>
|
||||
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-logo-url" className="text-xs font-bold uppercase tracking-widest text-slate-400">Logo URL</label><input id="footer-logo-url" type="url" value={logoUrl} onChange={event => setLogoUrl(event.target.value)} className="admin-input w-full" placeholder="https://example.com/logo.png" />{logoUrl && <div className="mt-3 inline-flex min-h-16 items-center rounded-lg border border-white/10 bg-black/20 p-3"><img src={logoUrl} alt="Footer logo preview" className="max-h-12 max-w-56 object-contain" /></div>}</div>
|
||||
<div className="space-y-2 md:col-span-2"><label className="text-xs font-bold uppercase tracking-widest text-slate-400">Footer description</label><div className="flex gap-2">{(['th', 'en'] as const).map(language => <button key={language} type="button" onClick={() => setActiveLanguage(language)} className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase ${activeLanguage === language ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'}`}>{language}</button>)}</div><textarea id="footer-description" value={description[activeLanguage] || ''} onChange={event => setDescription(current => ({ ...current, [activeLanguage]: event.target.value }))} className="admin-input min-h-24 w-full resize-y" placeholder={activeLanguage === 'th' ? 'กลิ่นหอมที่ออกแบบมาเพื่อบ้านของคุณ' : 'Scents designed for your home.'} /><p className="text-[10px] text-slate-500">Descriptions are saved per language and rendered using the visitor's locale.</p></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export const ADMIN_TOKEN_KEY = 'homaroma_admin_token';
|
||||
export const ADMIN_USER_KEY = 'homaroma_admin_user';
|
||||
|
||||
export function getAdminToken(): string | null {
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string): void {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getAdminUser(): any | null {
|
||||
const userStr = localStorage.getItem(ADMIN_USER_KEY);
|
||||
if (!userStr) return null;
|
||||
try {
|
||||
return JSON.parse(userStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAdminUser(user: any): void {
|
||||
localStorage.setItem(ADMIN_USER_KEY, typeof user === 'string' ? user : JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearAdminAuth(): void {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
localStorage.removeItem(ADMIN_USER_KEY);
|
||||
}
|
||||
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
const token = getAdminToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
Reference in new issue
Block a user