This commit is contained in:
Your Name committed 2026-09-28 04:43:13 +07:00
1 parent 5e724fae20
commit 4014e4d97b
18 files changed
+236 -60

No files matched your search

+1 -1
View File
@@ -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" />
+7 -4
View File
@@ -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 -1
View File
@@ -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>
+2 -1
View File
@@ -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
+5 -3
View File
@@ -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);
+3 -1
View File
@@ -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>
+3 -1
View File
@@ -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>
+7 -4
View File
@@ -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">
&copy; 2026 HomAroma & Essential Oils
&copy; {new Date().getFullYear()} {brandName}
</p>
</div>
</div>
+7 -7
View File
@@ -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;
+10 -6
View File
@@ -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)
+8 -3
View File
@@ -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>
+33 -4
View File
@@ -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>
+40
View File
@@ -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;
}