This commit is contained in:
Your Name committed 2026-09-14 11:46:07 +07:00
1 parent d5ea45a536
commit afd6904c48
20 files changed
+5435 -51

No files matched your search

+9
View File
@@ -63,7 +63,11 @@ import VietnamSalesMapPage from './pages/analytics/VietnamSalesMapPage'
import RepeatCustomersPage from './pages/analytics/RepeatCustomersPage'
import RepeatCustomersCalendarPage from './pages/analytics/RepeatCustomersCalendarPage'
import RepeatCustomersTimelinePage from './pages/analytics/RepeatCustomersTimelinePage'
import RepeatCustomersActionPlanPage from './pages/analytics/RepeatCustomersActionPlanPage'
import RepeatCustomerDetailPage from './pages/analytics/RepeatCustomerDetailPage'
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
function App() {
return (
@@ -133,6 +137,11 @@ function App() {
<Route path="/business-analytics/repeat-customers" element={<RepeatCustomersPage />} />
<Route path="/business-analytics/repeat-customers-calendar" element={<RepeatCustomersCalendarPage />} />
<Route path="/business-analytics/repeat-customers-timeline" element={<RepeatCustomersTimelinePage />} />
<Route path="/business-analytics/repeat-customers-action-plan" element={<RepeatCustomersActionPlanPage />} />
<Route path="/business-analytics/repeat-customer-detail" element={<RepeatCustomerDetailPage />} />
{/* Marketplace CRM */}
<Route path="/crm/marketplace-customers" element={<MarketplaceCrmPage />} />
<Route path="/crm/marketplace-customers/:id" element={<MarketplaceCustomerDetailPage />} />
{/* Tools */}
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
+7 -6
View File
@@ -38,6 +38,7 @@ import {
Map,
Calendar,
Clock,
Target,
} from 'lucide-react'
interface SidebarProps {
@@ -52,14 +53,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics')
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
const isInTools = location.pathname.startsWith('/tools')
const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis')
const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy
const isInShopee = location.pathname.startsWith('/shopee')
const isInLazada = location.pathname.startsWith('/lazada')
const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts', '/crm'].some(p => location.pathname.startsWith(p)) || isRepeatCustomers
const isInMenuManager = location.pathname.startsWith('/menu-manager')
const isInBackup = location.pathname.startsWith('/backup')
const isInSettings = location.pathname.startsWith('/settings')
@@ -101,9 +103,6 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{ icon: Package, label: t('nav.productSalesMap', 'Product Sales Map'), to: '/business-analytics/sales-map-products' },
{ icon: Map, label: t('nav.malaysiaSalesMap', 'Malaysia Map'), to: '/business-analytics/malaysia-map' },
{ icon: Map, label: t('nav.vietnamSalesMap', 'Vietnam Map'), to: '/business-analytics/vietnam-map' },
{ icon: UserCheck, label: t('nav.repeatCustomers', 'Repeat Customers'), to: '/business-analytics/repeat-customers' },
{ icon: Calendar, label: t('nav.repeatCustomersCalendar', 'Repeat Customers (Calendar)'), to: '/business-analytics/repeat-customers-calendar' },
{ icon: Clock, label: t('nav.repeatCustomersTimeline', 'Repeat Customers (Timeline)'), to: '/business-analytics/repeat-customers-timeline' },
]
const TOOL_MENU = [
@@ -146,7 +145,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
]
const CUSTOMER_MENU = [
{ icon: Users, label: t('nav.customerList', 'Customers'), to: '/customers' },
{ icon: Users, label: t('nav.marketplaceCrm', 'Marketplace CRM'), to: '/crm/marketplace-customers' },
{ icon: Target, label: t('nav.repeatCustomersActionPlan', 'Repeat Customers (CRM & Action)'), to: '/business-analytics/repeat-customers-action-plan' },
{ icon: UserCheck, label: t('nav.customerList', 'Web & LINE Customers'), to: '/customers' },
{ icon: Users, label: t('nav.newsletterSubscribers', 'Subscribers'), to: '/subscribers' },
{ icon: MessageSquare, label: t('nav.contactMessages', 'Contact Messages'), to: '/contacts' },
]
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag } from 'lucide-react';
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag, ExternalLink } from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht } from '../../utils/thaiGeoUtils';
@@ -164,9 +164,22 @@ export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatC
<span className="w-6 h-6 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-xs font-mono text-slate-300 font-medium">
#{orders.length - idx}
</span>
<span className="font-mono text-sm font-semibold text-white">
{order.orderId}
</span>
{order.channel === 'tiktok' || customer.primaryChannel === 'tiktok' ? (
<a
href={`https://seller-th.tiktok.com/order/detail?order_no=${order.orderId}&shop_region=TH`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-semibold text-rose-400 hover:text-rose-300 hover:underline inline-flex items-center gap-1 group transition-colors"
title="เปิดดูออเดอร์ใน TikTok Seller Center (แท็บใหม่)"
>
<span>{order.orderId}</span>
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover:opacity-100 transition-opacity" />
</a>
) : (
<span className="font-mono text-sm font-semibold text-white">
{order.orderId}
</span>
)}
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/5">
{order.shop}
</span>
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { Table2, Calendar, Clock } from 'lucide-react';
import { Table2, Calendar, Clock, Target, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function RepeatCustomersNavTabs() {
@@ -24,6 +24,18 @@ export default function RepeatCustomersNavTabs() {
icon: Clock,
badge: 'Predictive',
},
{
to: '/business-analytics/repeat-customers-action-plan',
label: t('repeatCustomers.tabsActionPlan', 'แผนเสนอโปรโมชัน (Action Plan)'),
icon: Target,
badge: 'Action Plan',
},
{
to: '/crm/marketplace-customers',
label: t('repeatCustomers.tabsCrm', 'จัดการลูกค้า CRM (Tracking)'),
icon: Users,
badge: 'Live CRM',
},
];
return (
@@ -0,0 +1,381 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
ShoppingBag,
Package,
Calendar,
MapPin,
RefreshCw,
Clock,
DollarSign,
Tag,
Truck,
Store,
ChevronRight,
UserCheck,
ExternalLink,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
interface OrderItem {
name: string;
variation?: string;
quantity: number;
price: number;
}
interface OrderHistory {
orderId: string;
channel: 'tiktok' | 'shopee' | 'lazada';
shop: string;
date: string;
status: string;
amount: number;
shippingFee: number;
trackingId?: string;
deliveryOption?: string;
items: OrderItem[];
}
export default function RepeatCustomerDetailPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const channel = searchParams.get('channel') || 'all';
const identifier = searchParams.get('identifier') || '';
const fallbackName = searchParams.get('name') || identifier;
const [loading, setLoading] = useState(true);
const [orders, setOrders] = useState<OrderHistory[]>([]);
const [customerDetails, setCustomerDetails] = useState<any>(null);
const fetchCustomerData = useCallback(() => {
if (!identifier) {
setLoading(false);
return;
}
setLoading(true);
const params = new URLSearchParams();
params.set('channel', channel);
params.set('identifier', identifier);
fetch(`${API_BASE}/business-analytics/repeat-customers/detail?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
setOrders(data.orders || []);
setCustomerDetails(data.customer || null);
})
.catch((err) => console.error('Failed to load customer detail page:', err))
.finally(() => setLoading(false));
}, [channel, identifier]);
useEffect(() => {
fetchCustomerData();
}, [fetchCustomerData]);
const getChannelBadge = (ch: string) => {
switch (ch) {
case 'tiktok':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
TikTok Shop
</span>
);
case 'shopee':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
Shopee
</span>
);
case 'lazada':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
Lazada
</span>
);
default:
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
{ch}
</span>
);
}
};
const getStatusBadge = (status: string) => {
const s = (status || '').toUpperCase();
if (s.includes('DELIVERED') || s.includes('COMPLETED') || s.includes('SUCCESS') || s.includes('FINISHED')) {
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/20">
{status}
</span>
);
}
if (s.includes('CANCEL') || s.includes('REFUND') || s.includes('RETURN')) {
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/20">
{status}
</span>
);
}
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20">
{status || 'COMPLETED'}
</span>
);
};
const displayName = customerDetails?.displayName || fallbackName || 'ไม่ระบุชื่อ';
const totalSpent = customerDetails?.totalSpent ?? orders.reduce((sum, o) => sum + (o.amount || 0), 0);
const totalOrders = customerDetails?.totalOrders ?? orders.length;
const location = customerDetails?.location || '';
const primaryChannel = customerDetails?.channel || channel;
const shopName = customerDetails?.shop || (orders.length > 0 ? orders[0]?.shop : '');
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1400px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Navigation / Header Bar ────────────────────────────── */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/60 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center gap-3.5">
<button
onClick={() => navigate(-1)}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all border border-white/10 group"
title="ย้อนกลับ"
>
<ArrowLeft className="w-5 h-5 transition-transform group-hover:-translate-x-0.5" />
</button>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{displayName}
</h1>
{getChannelBadge(primaryChannel)}
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
รายละเอียดลูกค้าและประวัติคำสั่งซื้อทั้งหมด
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchCustomerData}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
รีเฟรช
</button>
</div>
</div>
{/* ── Customer Summary Profile Card ──────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Profile Card */}
<div className="md:col-span-2 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md space-y-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center font-bold text-xl text-indigo-400">
{displayName.slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold text-white">{displayName}</span>
{getChannelBadge(primaryChannel)}
</div>
<span className="text-xs text-slate-400 font-mono block mt-0.5">
ID / Username: {identifier || '-'}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-white/5 text-xs text-slate-300">
{location && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-slate-500 shrink-0" />
<span className="truncate">{location}</span>
</div>
)}
{shopName && (
<div className="flex items-center gap-2">
<Store className="w-4 h-4 text-slate-500 shrink-0" />
<span className="truncate">ร้าน: {shopName}</span>
</div>
)}
</div>
</div>
{/* Total Spent KPI */}
<div className="bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs">ยอดซื้อสะสมทั้งหมด</span>
<DollarSign className="w-4 h-4 text-emerald-400" />
</div>
<div className="my-2">
<div className="text-2xl font-bold font-mono text-emerald-400">
{fmtBaht(totalSpent)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">รวมทุกคำสั่งซื้อสำเร็จ</p>
</div>
</div>
{/* Total Orders KPI */}
<div className="bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs">จำนวนคำสั่งซื้อ</span>
<Package className="w-4 h-4 text-indigo-400" />
</div>
<div className="my-2">
<div className="text-2xl font-bold font-mono text-indigo-300">
{fmtNum(totalOrders)} <span className="text-sm font-normal text-slate-400">ครั้ง</span>
</div>
<p className="text-[11px] text-slate-500 mt-0.5">
{totalOrders >= 2 ? 'ลูกค้าซื้อซ้ำ (Repeat Customer)' : 'ลูกค้าใหม่ (First-time)'}
</p>
</div>
</div>
</div>
{/* ── Orders History List ─────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-indigo-400" />
ประวัติคำสั่งซื้อทั้งหมด ({orders.length} รายการ)
</h2>
</div>
{loading ? (
<div className="py-24 text-center text-slate-400 bg-slate-900/50 rounded-2xl border border-white/10">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังโหลดข้อมูลคำสั่งซื้อ...
</div>
) : orders.length === 0 ? (
<div className="py-16 text-center text-slate-500 bg-slate-900/50 rounded-2xl border border-white/10">
<ShoppingBag className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบประวัติคำสั่งซื้อของลูกค้ารายนี้
</div>
) : (
<div className="space-y-4">
{orders.map((o, idx) => (
<div
key={o.orderId || idx}
className="bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md hover:border-indigo-500/30 transition-all space-y-4"
>
{/* Order Header */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="flex items-center gap-3">
<span className="w-7 h-7 rounded-lg bg-slate-800 text-indigo-400 font-bold text-xs flex items-center justify-center border border-white/10 font-mono">
#{orders.length - idx}
</span>
<div>
<div className="flex items-center gap-2 flex-wrap">
{o.channel === 'tiktok' || primaryChannel === 'tiktok' ? (
<a
href={`https://seller-th.tiktok.com/order/detail?order_no=${o.orderId}&shop_region=TH`}
target="_blank"
rel="noopener noreferrer"
className="font-mono font-bold text-rose-400 hover:text-rose-300 hover:underline text-sm inline-flex items-center gap-1 group transition-colors"
title="เปิดดูออเดอร์ใน TikTok Seller Center (แท็บใหม่)"
>
<span>{o.orderId}</span>
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover:opacity-100 transition-opacity" />
</a>
) : (
<span className="font-mono font-bold text-white text-sm">
{o.orderId}
</span>
)}
{getStatusBadge(o.status)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
<span className="flex items-center gap-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
{o.date}
</span>
{o.shop && (
<>
<span>•</span>
<span className="text-slate-400">ร้าน: {o.shop}</span>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<span className="text-[11px] text-slate-500 block">ยอดรวมออเดอร์</span>
<span className="text-base font-bold font-mono text-emerald-400">
{fmtBaht(o.amount)}
</span>
</div>
</div>
{/* Items List */}
<div className="space-y-2 bg-slate-950/40 p-3.5 rounded-xl border border-white/5">
<span className="text-[11px] font-semibold text-slate-400 block mb-2">
รายการสินค้า ({o.items?.length || 0} รายการ):
</span>
{o.items && o.items.length > 0 ? (
o.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-start justify-between gap-4 py-1.5 border-b border-white/5 last:border-0 text-xs"
>
<div className="flex-1">
<p className="text-slate-200 font-medium line-clamp-1">{item.name}</p>
{item.variation && (
<span className="text-[11px] text-slate-400 mt-0.5 block">
ตัวเลือก: {item.variation}
</span>
)}
</div>
<div className="text-right shrink-0">
<span className="text-slate-400 font-mono">
x{item.quantity} •{' '}
</span>
<span className="font-bold font-mono text-slate-200">
{fmtBaht(item.price)}
</span>
</div>
</div>
))
) : (
<p className="text-xs text-slate-500">ไม่มีข้อมูลรายละเอียดสินค้า</p>
)}
</div>
{/* Extra Order Info (Tracking / Shipping) */}
{(o.trackingId || o.deliveryOption || o.shippingFee > 0) && (
<div className="flex flex-wrap items-center justify-between text-xs text-slate-400 pt-1">
<div className="flex items-center gap-3">
{o.trackingId && (
<span className="flex items-center gap-1 font-mono text-slate-400">
<Truck className="w-3.5 h-3.5 text-slate-500" />
Tracking: {o.trackingId}
</span>
)}
{o.deliveryOption && <span>({o.deliveryOption})</span>}
</div>
{o.shippingFee > 0 && (
<span className="text-slate-500">
ค่าจัดส่ง: {fmtBaht(o.shippingFee)}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users,
UserCheck,
@@ -27,6 +28,7 @@ type SortBy = 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween';
export default function RepeatCustomersPage() {
const { t } = useTranslation();
const navigate = useNavigate();
// Filters state
const [channel, setChannel] = useState<ChannelType>('all');
@@ -127,6 +129,7 @@ export default function RepeatCustomersPage() {
'First Order Date',
'Last Order Date',
'Avg Days Between Orders',
'Predicted Next Order Date',
'Location',
'Top Purchased Products',
];
@@ -142,6 +145,7 @@ export default function RepeatCustomersPage() {
`"${c.firstOrderDate || ''}"`,
`"${c.lastOrderDate || ''}"`,
c.daysBetweenOrders || 0,
`"${c.predictedNextOrderDate || ''}"`,
`"${c.location || ''}"`,
`"${(c.topProducts || []).join(', ')}"`,
]);
@@ -200,6 +204,55 @@ export default function RepeatCustomersPage() {
}
};
const getDaysUntilPredicted = (predictedDateStr: string): number | null => {
if (!predictedDateStr) return null;
const parts = predictedDateStr.split('-');
if (parts.length !== 3) return null;
const pYear = parseInt(parts[0], 10);
const pMonth = parseInt(parts[1], 10) - 1;
const pDay = parseInt(parts[2], 10);
if (isNaN(pYear) || isNaN(pMonth) || isNaN(pDay)) return null;
const predictedDate = new Date(pYear, pMonth, pDay);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diffMs = predictedDate.getTime() - today.getTime();
return Math.round(diffMs / (1000 * 60 * 60 * 24));
};
const renderPredictedDateDiff = (predictedDateStr: string) => {
const diffDays = getDaysUntilPredicted(predictedDateStr);
if (diffDays === null) return null;
if (diffDays > 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/25 whitespace-nowrap">
เหลืออีก {diffDays} วัน
</span>
);
} else if (diffDays === 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30 animate-pulse whitespace-nowrap">
ครบกำหนดวันนี้
</span>
);
} else {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/25 whitespace-nowrap">
เกินกำหนด {Math.abs(diffDays)} วัน
</span>
);
}
};
const handleCustomerClick = (cust: any) => {
const ch = cust.primaryChannel || 'all';
const id = cust.identifier || cust.displayName || '';
const name = cust.displayName || '';
navigate(`/business-analytics/repeat-customer-detail?channel=${encodeURIComponent(ch)}&identifier=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`);
};
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Header Bar ────────────────────────────────────────── */}
@@ -733,6 +786,7 @@ export default function RepeatCustomersPage() {
<th className="py-3.5 px-4 text-right">ยอดเฉลี่ย / ออเดอร์</th>
<th className="py-3.5 px-4">ซื้อล่าสุด</th>
<th className="py-3.5 px-4 text-center">รอบซื้อซ้ำเฉลี่ย</th>
<th className="py-3.5 px-4">คาดการณ์รอบถัดไป</th>
<th className="py-3.5 px-4">สินค้าที่ซื้อบ่อย</th>
<th className="py-3.5 px-4 text-right">การจัดการ</th>
</tr>
@@ -740,14 +794,14 @@ export default function RepeatCustomersPage() {
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={10} className="py-20 text-center text-slate-400">
<td colSpan={11} className="py-20 text-center text-slate-400">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังประมวลผลข้อมูลลูกค้าซื้อซ้ำ...
</td>
</tr>
) : !data?.customers || data.customers.length === 0 ? (
<tr>
<td colSpan={10} className="py-16 text-center text-slate-500">
<td colSpan={11} className="py-16 text-center text-slate-500">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
</td>
@@ -755,6 +809,12 @@ export default function RepeatCustomersPage() {
) : (
data.customers.map((cust: any, idx: number) => {
const rowNumber = (page - 1) * limit + idx + 1;
const predictedDate = cust.predictedNextOrderDate || (
cust.lastOrderDate && cust.daysBetweenOrders > 0
? new Date(new Date(cust.lastOrderDate.replace(' ', 'T')).getTime() + cust.daysBetweenOrders * 86400000).toISOString().slice(0, 10)
: ''
);
return (
<tr
key={cust.id || idx}
@@ -766,7 +826,11 @@ export default function RepeatCustomersPage() {
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-100 text-sm">
<span
onClick={() => handleCustomerClick(cust)}
className="font-semibold text-slate-100 text-sm hover:text-indigo-300 hover:underline cursor-pointer transition-colors"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName}
</span>
{cust.location && (
@@ -831,6 +895,21 @@ export default function RepeatCustomersPage() {
)}
</td>
<td className="py-3 px-4">
{predictedDate ? (
<div className="flex flex-col gap-1">
<span className="font-mono font-bold text-amber-400">
{predictedDate}
</span>
<div>
{renderPredictedDateDiff(predictedDate)}
</div>
</div>
) : (
<span className="text-slate-600">-</span>
)}
</td>
<td className="py-3 px-4 max-w-[200px]">
<div className="flex flex-wrap gap-1">
{cust.topProducts?.map((prod: string, pIdx: number) => (
@@ -847,10 +926,10 @@ export default function RepeatCustomersPage() {
<td className="py-3 px-4 text-right">
<button
onClick={() => setSelectedCustomer(cust)}
onClick={() => handleCustomerClick(cust)}
className="px-2.5 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30 hover:text-white text-xs font-medium transition-all"
>
ดูออเดอร์
ดูรายละเอียด
</button>
</td>
</tr>
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Clock,
RefreshCw,
@@ -17,13 +18,13 @@ import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type ChurnStatus = 'all' | 'due_soon' | 'healthy' | 'churn_risk';
export default function RepeatCustomersTimelinePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [channel, setChannel] = useState<ChannelType>('all');
const [churnStatus, setChurnStatus] = useState<ChurnStatus>('all');
@@ -34,7 +35,6 @@ export default function RepeatCustomersTimelinePage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<any>(null);
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
// Debounce search
useEffect(() => {
@@ -107,6 +107,55 @@ export default function RepeatCustomersTimelinePage() {
}
};
const getDaysUntilPredicted = (predictedDateStr: string): number | null => {
if (!predictedDateStr) return null;
const parts = predictedDateStr.split('-');
if (parts.length !== 3) return null;
const pYear = parseInt(parts[0], 10);
const pMonth = parseInt(parts[1], 10) - 1;
const pDay = parseInt(parts[2], 10);
if (isNaN(pYear) || isNaN(pMonth) || isNaN(pDay)) return null;
const predictedDate = new Date(pYear, pMonth, pDay);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diffMs = predictedDate.getTime() - today.getTime();
return Math.round(diffMs / (1000 * 60 * 60 * 24));
};
const renderPredictedDateDiff = (predictedDateStr: string) => {
const diffDays = getDaysUntilPredicted(predictedDateStr);
if (diffDays === null) return null;
if (diffDays > 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">
เหลืออีก {diffDays} วัน
</span>
);
} else if (diffDays === 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30 animate-pulse">
ครบกำหนดวันนี้
</span>
);
} else {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/25">
เกินกำหนด {Math.abs(diffDays)} วัน
</span>
);
}
};
const handleCustomerClick = (cust: any) => {
const ch = cust.primaryChannel || 'all';
const id = cust.identifier || cust.displayName || '';
const name = cust.displayName || '';
navigate(`/business-analytics/repeat-customer-detail?channel=${encodeURIComponent(ch)}&identifier=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`);
};
const getChannelBadge = (ch: string) => {
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok Shop</span>;
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
@@ -365,12 +414,20 @@ export default function RepeatCustomersTimelinePage() {
{/* Card Top: Customer Identity & Status */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-slate-800/80 border border-white/10 flex items-center justify-center font-bold text-sm text-indigo-400">
<div
onClick={() => handleCustomerClick(cust)}
className="w-10 h-10 rounded-xl bg-slate-800/80 border border-white/10 flex items-center justify-center font-bold text-sm text-indigo-400 cursor-pointer hover:bg-indigo-600/20 hover:border-indigo-500/40 hover:scale-105 transition-all"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base text-white">
<span
onClick={() => handleCustomerClick(cust)}
className="font-bold text-base text-white hover:text-indigo-300 hover:underline cursor-pointer transition-colors"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName}
</span>
{getChannelBadge(cust.primaryChannel)}
@@ -396,7 +453,7 @@ export default function RepeatCustomersTimelinePage() {
</div>
{/* Right side: Cycle metrics */}
<div className="flex items-center gap-4 text-xs bg-slate-950/60 px-3 py-2 rounded-xl border border-white/5">
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-xs bg-slate-950/60 px-3.5 py-2 rounded-xl border border-white/5">
<div className="text-right">
<span className="text-[11px] text-slate-500 block">รอบซื้อเฉลี่ย</span>
<span className="font-bold font-mono text-indigo-400">
@@ -413,9 +470,12 @@ export default function RepeatCustomersTimelinePage() {
<div className="w-px h-6 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-500 block">คาดการณ์รอบถัดไป</span>
<span className="font-bold font-mono text-amber-400">
{cust.predictedNextOrderDate}
</span>
<div className="flex items-center gap-1.5 justify-end mt-0.5">
<span className="font-bold font-mono text-amber-400">
{cust.predictedNextOrderDate}
</span>
{renderPredictedDateDiff(cust.predictedNextOrderDate)}
</div>
</div>
</div>
</div>
@@ -450,8 +510,8 @@ export default function RepeatCustomersTimelinePage() {
{/* Order Node */}
<div
className="relative z-10 flex flex-col items-center group cursor-pointer"
onClick={() => setSelectedCustomer(cust)}
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)})`}
onClick={() => handleCustomerClick(cust)}
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)}) — คลิกเพื่อดูรายละเอียด`}
>
{/* Node Circle */}
<div
@@ -519,19 +579,41 @@ export default function RepeatCustomersTimelinePage() {
})}
{/* Predicted Next Order Marker */}
<div className="relative z-10 flex flex-col items-center">
<div className="w-5 h-5 rounded-full border-2 border-dashed border-amber-400 bg-amber-500/20 flex items-center justify-center animate-pulse">
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
</div>
<div className="mt-1.5 text-center whitespace-nowrap">
<span className="text-[10px] text-amber-300 font-medium block">
รอบถัดไป
</span>
<span className="text-[10px] font-mono text-amber-400/80 block">
~{cust.predictedNextOrderDate.slice(5, 10)}
</span>
</div>
</div>
{(() => {
const diffDays = getDaysUntilPredicted(cust.predictedNextOrderDate);
return (
<div className="relative z-10 flex flex-col items-center">
<div className="w-5 h-5 rounded-full border-2 border-dashed border-amber-400 bg-amber-500/20 flex items-center justify-center animate-pulse">
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
</div>
<div className="mt-1.5 text-center whitespace-nowrap">
<span className="text-[10px] text-amber-300 font-medium block">
รอบถัดไป
</span>
<span className="text-[10px] font-mono text-amber-400/80 block">
~{cust.predictedNextOrderDate.slice(5, 10)}
</span>
{diffDays !== null && (
<span
className={`text-[9px] font-mono font-semibold block ${
diffDays > 0
? 'text-emerald-400'
: diffDays === 0
? 'text-amber-300 animate-pulse'
: 'text-rose-400'
}`}
>
{diffDays > 0
? `(อีก ${diffDays} วัน)`
: diffDays === 0
? '(วันนี้)'
: `(เลย ${Math.abs(diffDays)} วัน)`}
</span>
)}
</div>
</div>
);
})()}
</div>
</div>
</div>
@@ -543,11 +625,11 @@ export default function RepeatCustomersTimelinePage() {
</span>
<button
onClick={() => setSelectedCustomer(cust)}
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium border border-indigo-500/30 transition-all"
onClick={() => handleCustomerClick(cust)}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium border border-indigo-500/30 transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
ดูประวัติคำสั่งซื้อทั้งหมด ({cust.orderCount} ครั้ง)
ดูรายละเอียดลูกค้าและประวัติทั้งหมด ({cust.orderCount} ครั้ง)
</button>
</div>
</div>
@@ -603,14 +685,6 @@ export default function RepeatCustomersTimelinePage() {
</div>
</div>
)}
{/* ── Order Detail Modal ─────────────────────────────────────── */}
{selectedCustomer && (
<RepeatCustomerOrdersModal
customer={selectedCustomer}
onClose={() => setSelectedCustomer(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,891 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users,
Search,
RefreshCw,
PhoneCall,
MessageSquare,
CheckCircle2,
Clock,
AlertCircle,
Tag,
Gift,
Flame,
Crown,
ChevronLeft,
ChevronRight,
ExternalLink,
Edit3,
Save,
X,
History,
TrendingUp,
DollarSign,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type CRMStatus = 'all' | 'pending' | 'contacted' | 'converted' | 'unresponsive';
type StrategyType = 'all' | 'due_restock' | 'win_back' | 'vip_reward' | 'nurture';
interface CrmCustomer {
id: number;
channel: string;
shop: string;
identifier: string;
displayName: string;
location?: string;
province?: string;
district?: string;
orderCount: number;
totalSpent: number;
avgIntervalDays: number;
firstOrderDate?: string;
lastOrderDate?: string;
predictedNextOrderDate?: string;
topProducts?: string[];
crm?: {
id: number;
status: 'pending' | 'contacted' | 'converted' | 'unresponsive';
strategyType: string;
promoCodeSent?: string;
discountOffered?: string;
lastContactedAt?: string;
nextFollowUpDate?: string;
notes?: string;
assignedTo?: string;
activities?: Array<{
id: number;
action: string;
content: string;
author?: string;
createdAt: string;
}>;
updatedAt?: string;
};
}
export default function MarketplaceCrmPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [channel, setChannel] = useState<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<CRMStatus>('all');
const [strategyFilter, setStrategyFilter] = useState<StrategyType>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [customers, setCustomers] = useState<CrmCustomer[]>([]);
const [summary, setSummary] = useState({
totalInCrm: 0,
pendingCount: 0,
contactedCount: 0,
convertedCount: 0,
unresponsiveCount: 0,
conversionRate: 0,
totalRevenueInCrm: 0,
});
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
totalCount: 0,
totalPages: 1,
});
// Edit Note Modal / Drawer State
const [editingCustomer, setEditingCustomer] = useState<CrmCustomer | null>(null);
const [editNotes, setEditNotes] = useState('');
const [editPromoCode, setEditPromoCode] = useState('');
const [editDiscount, setEditDiscount] = useState('');
const [editStatus, setEditStatus] = useState<string>('pending');
const [editFollowUpDate, setEditFollowUpDate] = useState('');
const [savingEdit, setSavingEdit] = useState(false);
// New Activity Note Modal
const [newActivityContent, setNewActivityContent] = useState('');
const [addingActivity, setAddingActivity] = useState(false);
// Debounce search
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(handler);
}, [search]);
// Fetch CRM Customers
const fetchCrmData = useCallback(() => {
setLoading(true);
const params = new URLSearchParams();
if (channel !== 'all') params.set('channel', channel);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (strategyFilter !== 'all') params.set('strategyType', strategyFilter);
if (debouncedSearch) params.set('search', debouncedSearch);
params.set('page', String(page));
params.set('limit', String(limit));
fetch(`${API_BASE}/crm/customers?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data?.customers) {
setCustomers(data.customers);
if (data.summary) setSummary(data.summary);
if (data.pagination) setPagination(data.pagination);
}
})
.catch((err) => console.error('Failed to load CRM data:', err))
.finally(() => setLoading(false));
}, [channel, statusFilter, strategyFilter, debouncedSearch, page, limit]);
useEffect(() => {
fetchCrmData();
}, [fetchCrmData]);
// Quick Status Update
const handleQuickStatusChange = async (
customerId: number,
newStatus: 'pending' | 'contacted' | 'converted' | 'unresponsive'
) => {
try {
// Optimistic update
setCustomers((prev) =>
prev.map((c) =>
c.id === customerId
? {
...c,
crm: c.crm ? { ...c.crm, status: newStatus } : undefined,
}
: c
)
);
await fetch(`${API_BASE}/crm/customers/${customerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({ status: newStatus }),
});
fetchCrmData();
} catch (e) {
console.error('Failed to update status:', e);
}
};
// Open Edit Modal
const openEditModal = (cust: CrmCustomer) => {
setEditingCustomer(cust);
setEditNotes(cust.crm?.notes || '');
setEditPromoCode(cust.crm?.promoCodeSent || '');
setEditDiscount(cust.crm?.discountOffered || '');
setEditStatus(cust.crm?.status || 'pending');
setEditFollowUpDate(
cust.crm?.nextFollowUpDate ? cust.crm.nextFollowUpDate.slice(0, 10) : ''
);
setNewActivityContent('');
};
// Save Modal Changes
const handleSaveModal = async () => {
if (!editingCustomer) return;
setSavingEdit(true);
try {
await fetch(`${API_BASE}/crm/customers/${editingCustomer.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
notes: editNotes,
promoCodeSent: editPromoCode,
discountOffered: editDiscount,
status: editStatus,
nextFollowUpDate: editFollowUpDate || null,
}),
});
setEditingCustomer(null);
fetchCrmData();
} catch (e) {
console.error('Failed to save CRM record:', e);
} finally {
setSavingEdit(false);
}
};
// Add Activity Note
const handleAddActivity = async () => {
if (!editingCustomer || !newActivityContent.trim()) return;
setAddingActivity(true);
try {
await fetch(`${API_BASE}/crm/customers/${editingCustomer.id}/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
action: 'NOTE',
content: newActivityContent.trim(),
author: 'Admin',
}),
});
setNewActivityContent('');
fetchCrmData();
// Update local activities in editing modal
if (editingCustomer.crm) {
setEditingCustomer({
...editingCustomer,
crm: {
...editingCustomer.crm,
activities: [
{
id: Date.now(),
action: 'NOTE',
content: newActivityContent.trim(),
author: 'Admin',
createdAt: new Date().toISOString(),
},
...(editingCustomer.crm.activities || []),
],
},
});
}
} catch (e) {
console.error('Failed to add activity:', e);
} finally {
setAddingActivity(false);
}
};
const getChannelBadge = (ch: string) => {
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok</span>;
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">Lazada</span>;
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'converted':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
ซื้อซ้ำสำเร็จ
</span>
);
case 'contacted':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
<MessageSquare className="w-3.5 h-3.5 text-indigo-400" />
ส่งโปรแล้ว
</span>
);
case 'unresponsive':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-slate-500/20 text-slate-300 border border-slate-500/30">
<AlertCircle className="w-3.5 h-3.5 text-slate-400" />
ไม่สนใจ/ไม่ตอบ
</span>
);
case 'pending':
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<Clock className="w-3.5 h-3.5 text-amber-400" />
รอดำเนินการ
</span>
);
}
};
const getStrategyBadge = (type: string) => {
switch (type) {
case 'due_restock':
case 'due_soon':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/25">🔥 เตือนซื้อซ้ำ</span>;
case 'win_back':
case 'overdue':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/25">⚠️ Win-Back</span>;
case 'vip_reward':
case 'vip':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/25">👑 VIP Reward</span>;
default:
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">✨ Nurturing</span>;
}
};
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Header Bar ────────────────────────────────────────── */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md">
<div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
<Users className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{t('marketplaceCrm.title', 'CRM ติดตามลูกค้า Marketplace')}
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
ฐานข้อมูลบริหารจัดการ ติดตามสถานะ และบันทึกประวัติการทักหาลูกค้าที่นำเข้ามาจาก Marketplace
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => navigate('/business-analytics/repeat-customers-action-plan')}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-purple-600/20 hover:bg-purple-600/30 border border-purple-500/30 text-xs font-semibold text-purple-300 hover:text-white transition-all"
>
<Sparkles className="w-3.5 h-3.5" />
เปิดหน้า Action Plan
</button>
<button
onClick={() => fetchCrmData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
รีเฟรช
</button>
</div>
</div>
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
<RepeatCustomersNavTabs />
{/* ── CRM Overview KPI Cards ─────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3.5">
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400">ลูกค้าใน CRM ทั้งหมด</span>
<Users className="w-4 h-4 text-purple-400" />
</div>
<div className="mt-2 text-2xl font-bold text-white font-mono">
{fmtNum(summary.totalInCrm)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-purple-400/80 mt-0.5">ยอดซื้อสะสม {fmtBaht(summary.totalRevenueInCrm)}</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'pending' ? 'all' : 'pending'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'pending'
? 'border-amber-400 ring-2 ring-amber-400/50 bg-amber-950/25'
: 'border-amber-500/20 hover:border-amber-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-amber-300">⏳ รอดำเนินการ</span>
<Clock className="w-4 h-4 text-amber-400" />
</div>
<div className="mt-2 text-2xl font-bold text-amber-400 font-mono">
{fmtNum(summary.pendingCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-amber-300/80 mt-0.5">ยังไม่ได้ทักหรือส่งข้อเสนอ</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'contacted' ? 'all' : 'contacted'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'contacted'
? 'border-indigo-400 ring-2 ring-indigo-400/50 bg-indigo-950/25'
: 'border-indigo-500/20 hover:border-indigo-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-indigo-300">💬 ส่งโปรแล้ว</span>
<MessageSquare className="w-4 h-4 text-indigo-400" />
</div>
<div className="mt-2 text-2xl font-bold text-indigo-400 font-mono">
{fmtNum(summary.contactedCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-indigo-300/80 mt-0.5">รอฟีดแบ็ค / ออเดอร์ซื้อซ้ำ</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'converted' ? 'all' : 'converted'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'converted'
? 'border-emerald-400 ring-2 ring-emerald-400/50 bg-emerald-950/25'
: 'border-emerald-500/20 hover:border-emerald-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-emerald-300">✅ ซื้อซ้ำสำเร็จ</span>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2 text-2xl font-bold text-emerald-400 font-mono">
{fmtNum(summary.convertedCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-emerald-300/80 mt-0.5">ปิดการขายสำเร็จ</p>
</div>
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-sky-300">📈 Conversion Rate</span>
<TrendingUp className="w-4 h-4 text-sky-400" />
</div>
<div className="mt-2 text-2xl font-bold text-sky-400 font-mono">
{summary.conversionRate}%
</div>
<p className="text-[11px] text-sky-300/80 mt-0.5">สำเร็จเทียบกับกลุ่มที่ทักไป</p>
</div>
</div>
{/* ── Filters & Search Controls ──────────────────────────────── */}
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{/* Channel Filter */}
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
{(['all', 'tiktok', 'shopee', 'lazada'] as ChannelType[]).map((ch) => (
<button
key={ch}
onClick={() => {
setChannel(ch);
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === ch
? 'bg-purple-600 text-white font-semibold'
: 'text-slate-400 hover:text-white'
}`}
>
{ch === 'all' ? 'ทุกช่องทาง' : ch.toUpperCase()}
</button>
))}
</div>
{/* Status Filter Tabs */}
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
{[
{ id: 'all', label: 'ทั้งหมด' },
{ id: 'pending', label: '⏳ รอดำเนินการ' },
{ id: 'contacted', label: '💬 ส่งโปรแล้ว' },
{ id: 'converted', label: '✅ ซื้อซ้ำสำเร็จ' },
{ id: 'unresponsive', label: '❌ ไม่สนใจ' },
].map((st) => (
<button
key={st.id}
onClick={() => {
setStatusFilter(st.id as CRMStatus);
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
statusFilter === st.id
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-400 hover:text-white'
}`}
>
{st.label}
</button>
))}
</div>
</div>
{/* Search */}
<div className="relative min-w-[240px] flex-1 max-w-xs">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="ค้นหาชื่อลูกค้า, โน้ต, ID..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-950 border border-white/10 rounded-xl text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 transition-all"
/>
</div>
</div>
{/* ── CRM Table ──────────────────────────────────────────────── */}
<div className="bg-slate-900/50 rounded-2xl border border-white/10 overflow-hidden backdrop-blur-md">
{loading ? (
<div className="py-24 text-center text-slate-400">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังโหลดข้อมูลลูกค้าในระบบ CRM...
</div>
) : customers.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">ยังไม่มีลูกค้าในรายการ CRM ตามเงื่อนไขนี้</p>
<p className="text-xs text-slate-500 mt-1">
คุณสามารถกดปุ่ม <span className="text-indigo-400 font-semibold">"Import เข้า CRM"</span> ได้ที่หน้า Action Plan
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950/80 text-slate-400 border-b border-white/5 uppercase text-[11px] font-semibold">
<tr>
<th className="py-3 px-4">ลูกค้า & ช่องทาง</th>
<th className="py-3 px-4">สถิติซื้อ</th>
<th className="py-3 px-4">กลยุทธ์</th>
<th className="py-3 px-4">สถานะ CRM</th>
<th className="py-3 px-4">ข้อเสนอ / โค้ดลด</th>
<th className="py-3 px-4">บันทึกโน้ต (Notes)</th>
<th className="py-3 px-4 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{customers.map((c) => {
const crm = c.crm;
const curStatus = crm?.status || 'pending';
return (
<tr key={c.id} className="hover:bg-white/[0.02] transition-colors">
{/* Customer Info */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-2.5">
<div
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="w-8 h-8 rounded-lg bg-slate-800 border border-white/10 flex items-center justify-center font-bold text-xs text-purple-400 cursor-pointer hover:scale-105 hover:bg-purple-600/20 transition-all"
title="เปิดดูรายละเอียด CRM ลูกค้าเต็มหน้า"
>
{c.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-1.5 flex-wrap">
<span
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="font-semibold text-white hover:text-purple-300 hover:underline cursor-pointer"
title="เปิดดูรายละเอียด CRM ลูกค้าเต็มหน้า"
>
{c.displayName}
</span>
{getChannelBadge(c.channel)}
</div>
<span className="text-[11px] text-slate-500 font-mono block">
{c.identifier} {c.location ? `• ${c.location}` : ''}
</span>
</div>
</div>
</td>
{/* Stats */}
<td className="py-3.5 px-4">
<div className="font-mono">
<span className="text-emerald-400 font-semibold block">{fmtBaht(c.totalSpent)}</span>
<span className="text-slate-400 text-[11px] block">
{c.orderCount} ออเดอร์ (รอบ {c.avgIntervalDays} วัน)
</span>
</div>
</td>
{/* Strategy */}
<td className="py-3.5 px-4">
{getStrategyBadge(crm?.strategyType || 'due_restock')}
</td>
{/* Quick Status Dropdown / Buttons */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1">
<select
value={curStatus}
onChange={(e) =>
handleQuickStatusChange(
c.id,
e.target.value as 'pending' | 'contacted' | 'converted' | 'unresponsive'
)
}
className={`px-2.5 py-1 text-xs rounded-xl border font-semibold focus:outline-none transition-all ${
curStatus === 'converted'
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300'
: curStatus === 'contacted'
? 'bg-indigo-950/60 border-indigo-500/40 text-indigo-300'
: curStatus === 'unresponsive'
? 'bg-slate-900 border-slate-700 text-slate-400'
: 'bg-amber-950/60 border-amber-500/40 text-amber-300'
}`}
>
<option value="pending">⏳ รอดำเนินการ</option>
<option value="contacted">💬 ส่งโปรแล้ว</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ</option>
<option value="unresponsive">❌ ไม่สนใจ</option>
</select>
</div>
</td>
{/* Promo & Discount */}
<td className="py-3.5 px-4">
{crm?.promoCodeSent ? (
<div className="space-y-0.5">
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
{crm.promoCodeSent}
</span>
{crm.discountOffered && (
<span className="text-[11px] text-slate-400 block mt-0.5">{crm.discountOffered}</span>
)}
</div>
) : (
<span className="text-slate-600 text-xs">-</span>
)}
</td>
{/* Notes Preview */}
<td className="py-3.5 px-4 max-w-[200px]">
<p className="truncate text-slate-300 text-xs">
{crm?.notes || <span className="text-slate-600 italic">ไม่มีบันทึก</span>}
</p>
{crm?.activities?.[0] && (
<span className="text-[10px] text-slate-500 block truncate mt-0.5">
ล่าสุด: {crm.activities[0].content}
</span>
)}
</td>
{/* Actions */}
<td className="py-3.5 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => openEditModal(c)}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs transition-all"
title="แก้ไขโน้ตและสถานะการติดต่อ"
>
<Edit3 className="w-3.5 h-3.5 text-indigo-400" />
<span>จัดการ</span>
</button>
<button
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/5 transition-all"
title="ดูหน้า CRM ลูกค้ารายนี้แบบเต็มหน้า"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* ── Pagination Bar ─────────────────────────────────────────── */}
{pagination.totalPages > 1 && (
<div className="p-4 bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
<span>
กำลังแสดงหน้า <span className="font-semibold text-white">{pagination.page}</span> จาก{' '}
<span className="font-semibold text-white">{pagination.totalPages}</span> หน้า (ทั้งหมด{' '}
{fmtNum(pagination.totalCount)} รายการ)
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-3 py-1 rounded-lg bg-slate-950 font-mono font-semibold text-white border border-white/10">
{page} / {pagination.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
disabled={page >= pagination.totalPages || loading}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* ── Edit Note & Activity Modal ─────────────────────────────── */}
{editingCustomer && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-lg overflow-hidden shadow-2xl space-y-4">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-slate-950/60">
<div>
<h3 className="text-base font-bold text-white flex items-center gap-2">
<Edit3 className="w-4 h-4 text-indigo-400" />
จัดการบันทึก CRM: {editingCustomer.displayName}
</h3>
<span className="text-xs text-slate-500 font-mono">
{editingCustomer.channel.toUpperCase()} • {editingCustomer.identifier}
</span>
</div>
<button
onClick={() => setEditingCustomer(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Content */}
<div className="p-4 space-y-3.5 text-xs">
{/* Status & Follow-up Date */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-slate-400 block mb-1">สถานะติดตาม:</label>
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
>
<option value="pending">⏳ รอดำเนินการ</option>
<option value="contacted">💬 ส่งโปรแล้ว</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ</option>
<option value="unresponsive">❌ ไม่สนใจ/ไม่ตอบ</option>
</select>
</div>
<div>
<label className="text-slate-400 block mb-1">วันนัดติดตามผล:</label>
<input
type="date"
value={editFollowUpDate}
onChange={(e) => setEditFollowUpDate(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Promo Code & Discount */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-slate-400 block mb-1">โค้ดส่วนลดที่ส่ง:</label>
<input
type="text"
value={editPromoCode}
onChange={(e) => setEditPromoCode(e.target.value)}
placeholder="เช่น REPEAT10"
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white font-mono uppercase focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-slate-400 block mb-1">รายละเอียดส่วนลด:</label>
<input
type="text"
value={editDiscount}
onChange={(e) => setEditDiscount(e.target.value)}
placeholder="เช่น ลด 10% หรือส่งฟรี"
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Notes */}
<div>
<label className="text-slate-400 block mb-1">บันทึกโน้ตข้อมูลลูกค้า:</label>
<textarea
rows={3}
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="เช่น ชอบกลิ่นลาเวนเดอร์ แนะนำกลิ่นเปปเปอร์มินต์เพิ่ม นัดสั่งต้นเดือนหน้า..."
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-500 resize-none"
/>
</div>
{/* Activity Log List */}
<div className="pt-2 border-t border-white/5 space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold flex items-center gap-1">
<History className="w-3.5 h-3.5 text-indigo-400" />
ประวัติกิจกรรม (Activity Log):
</span>
</div>
{/* Add new quick activity */}
<div className="flex gap-2">
<input
type="text"
value={newActivityContent}
onChange={(e) => setNewActivityContent(e.target.value)}
placeholder="เพิ่มบันทึกการคุย/การโทรด่วน..."
className="flex-1 bg-slate-950 border border-white/10 rounded-xl px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-indigo-500"
/>
<button
onClick={handleAddActivity}
disabled={addingActivity || !newActivityContent.trim()}
className="px-3 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold disabled:opacity-50 transition-all"
>
{addingActivity ? '...' : 'เพิ่ม'}
</button>
</div>
<div className="max-h-36 overflow-y-auto space-y-1.5 pr-1">
{editingCustomer.crm?.activities && editingCustomer.crm.activities.length > 0 ? (
editingCustomer.crm.activities.map((act) => (
<div
key={act.id}
className="p-2 rounded-lg bg-slate-950/70 border border-white/5 flex items-start justify-between gap-2"
>
<div>
<p className="text-slate-200">{act.content}</p>
<span className="text-[10px] text-slate-500">โดย: {act.author || 'Admin'}</span>
</div>
<span className="text-[10px] text-slate-500 whitespace-nowrap">
{act.createdAt ? new Date(act.createdAt).toLocaleString('th-TH') : ''}
</span>
</div>
))
) : (
<span className="text-slate-500 text-xs italic block py-1">ยังไม่มีประวัติกิจกรรม</span>
)}
</div>
</div>
</div>
{/* Modal Actions */}
<div className="p-4 border-t border-white/5 bg-slate-950/60 flex items-center justify-end gap-2">
<button
onClick={() => setEditingCustomer(null)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium transition-all"
>
ยกเลิก
</button>
<button
onClick={handleSaveModal}
disabled={savingEdit}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold shadow-lg shadow-indigo-600/20 disabled:opacity-50 transition-all"
>
<Save className="w-3.5 h-3.5" />
<span>{savingEdit ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,585 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
Users,
ShoppingBag,
Clock,
Calendar,
DollarSign,
Tag,
CheckCircle2,
AlertCircle,
MessageSquare,
History,
PhoneCall,
Save,
ExternalLink,
ChevronRight,
Flame,
Crown,
Gift,
Zap,
MapPin,
RefreshCw,
Copy,
Check,
Package,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
interface OrderItem {
name: string;
variation?: string;
quantity: number;
price: number;
}
interface OrderHistory {
orderId: string;
channel: 'tiktok' | 'shopee' | 'lazada';
shop: string;
date: string;
status: string;
amount: number;
shippingFee: number;
trackingId?: string;
deliveryOption?: string;
items: OrderItem[];
}
export default function MarketplaceCustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
const [orders, setOrders] = useState<OrderHistory[]>([]);
// Form states for CRM editing
const [status, setStatus] = useState<string>('pending');
const [strategyType, setStrategyType] = useState<string>('due_restock');
const [promoCodeSent, setPromoCodeSent] = useState<string>('');
const [discountOffered, setDiscountOffered] = useState<string>('');
const [nextFollowUpDate, setNextFollowUpDate] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [savingCrm, setSavingCrm] = useState<boolean>(false);
// Activity Log
const [newActivity, setNewActivity] = useState<string>('');
const [addingActivity, setAddingActivity] = useState<boolean>(false);
const [copiedTemplate, setCopiedTemplate] = useState<boolean>(false);
const fetchCustomerDetail = useCallback(() => {
if (!id) return;
setLoading(true);
fetch(`${API_BASE}/crm/customers/${id}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data?.customer) {
const c = data.customer;
setCustomer(c);
setOrders(data.orders || []);
if (c.crm) {
setStatus(c.crm.status || 'pending');
setStrategyType(c.crm.strategyType || 'due_restock');
setPromoCodeSent(c.crm.promoCodeSent || '');
setDiscountOffered(c.crm.discountOffered || '');
setNextFollowUpDate(c.crm.nextFollowUpDate ? c.crm.nextFollowUpDate.slice(0, 10) : '');
setNotes(c.crm.notes || '');
}
}
})
.catch((err) => console.error('Failed to load CRM customer detail:', err))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchCustomerDetail();
}, [fetchCustomerDetail]);
// Save CRM changes
const handleSaveCrm = async () => {
if (!id) return;
setSavingCrm(true);
try {
await fetch(`${API_BASE}/crm/customers/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
status,
strategyType,
promoCodeSent,
discountOffered,
nextFollowUpDate: nextFollowUpDate || null,
notes,
}),
});
alert('บันทึกข้อมูล CRM เรียบร้อยแล้ว!');
fetchCustomerDetail();
} catch (e) {
console.error('Failed to save CRM:', e);
alert('เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSavingCrm(false);
}
};
// Add Activity Note
const handleAddActivity = async () => {
if (!id || !newActivity.trim()) return;
setAddingActivity(true);
try {
await fetch(`${API_BASE}/crm/customers/${id}/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
action: 'NOTE',
content: newActivity.trim(),
author: 'Admin',
}),
});
setNewActivity('');
fetchCustomerDetail();
} catch (e) {
console.error('Failed to add activity:', e);
} finally {
setAddingActivity(false);
}
};
// Quick Copy Message Template
const handleCopyChatTemplate = () => {
if (!customer) return;
const cleanName = customer.displayName.replace(/\s*\(\d+\)$/, '');
const promo = promoCodeSent || 'HOMAROMA';
const lastProd = orders?.[0]?.items?.[0]?.name || 'น้ำหอมปรับอากาศ/น้ำมันหอมระเหย HomAroma';
let msg = '';
if (strategyType === 'vip' || strategyType === 'vip_reward') {
msg = `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ HomAroma เสมอมาครับ\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} หรือกลิ่นใหม่ยอดฮิต 🌿 โค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
} else {
msg = `สวัสดีครับคุณ ${cleanName} 🌿 จาก HomAroma นะครับ\n\n${lastProd} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ [${promo}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨ สนใจเติมสินค้าทักหาเราได้เลยนะครับ!`;
}
navigator.clipboard.writeText(msg);
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2500);
};
const getChannelBadge = (ch: string) => {
switch (ch) {
case 'tiktok':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
TikTok Shop
</span>
);
case 'shopee':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
Shopee
</span>
);
case 'lazada':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
Lazada
</span>
);
default:
return null;
}
};
if (loading) {
return (
<div className="p-6 max-w-[1400px] mx-auto text-slate-100 flex flex-col items-center justify-center min-h-[500px]">
<div className="w-10 h-10 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลลูกค้า CRM...</p>
</div>
);
}
if (!customer) {
return (
<div className="p-6 max-w-[1400px] mx-auto text-slate-100">
<button
onClick={() => navigate('/crm/marketplace-customers')}
className="flex items-center gap-2 text-xs text-slate-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4" />
กลับหน้ารายการ CRM
</button>
<div className="p-8 text-center bg-slate-900/50 rounded-2xl border border-white/10 text-slate-400">
<AlertCircle className="w-10 h-10 mx-auto mb-2 opacity-40" />
ไม่พบข้อมูลลูกค้ารายนี้ในระบบ CRM
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1500px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Back Bar ───────────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium border border-white/10 transition-all"
>
<ArrowLeft className="w-4 h-4" />
<span>ย้อนกลับ</span>
</button>
<div className="flex items-center gap-2">
<button
onClick={fetchCustomerDetail}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium border border-white/10 transition-all"
>
<RefreshCw className="w-3.5 h-3.5" />
รีเฟรช
</button>
</div>
</div>
{/* ── Customer Header Banner ─────────────────────────────────── */}
<div className="p-6 rounded-2xl bg-gradient-to-r from-slate-900/90 via-indigo-950/40 to-slate-900/90 border border-white/10 backdrop-blur-md relative overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center font-bold text-2xl text-indigo-400 shadow-xl">
{customer.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{customer.displayName}
</h1>
{getChannelBadge(customer.channel)}
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
{customer.shop}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1.5 flex-wrap">
<span className="font-mono text-slate-300">ID: {customer.identifier}</span>
{customer.location && (
<>
<span>•</span>
<span className="flex items-center gap-1 text-slate-400">
<MapPin className="w-3.5 h-3.5 text-indigo-400" />
{customer.location}
</span>
</>
)}
</div>
</div>
</div>
{/* Quick Metrics */}
<div className="flex items-center gap-4 bg-slate-950/60 p-3 rounded-2xl border border-white/5">
<div className="text-right">
<span className="text-[11px] text-slate-400 block">ยอดซื้อรวมทั้งหมด</span>
<span className="text-lg font-bold font-mono text-emerald-400">
{fmtBaht(customer.totalSpent)}
</span>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-400 block">จำนวนออเดอร์</span>
<span className="text-lg font-bold font-mono text-indigo-400">
{customer.orderCount} ครั้ง
</span>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-400 block">รอบซื้อเฉลี่ย</span>
<span className="text-lg font-bold font-mono text-amber-400">
{customer.avgIntervalDays} วัน
</span>
</div>
</div>
</div>
</div>
{/* ── Main Two-Column Layout ─────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Left 2 Columns: CRM Management Console & Activities ─── */}
<div className="lg:col-span-2 space-y-6">
{/* CRM Action & Status Form Card */}
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-5">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<Zap className="w-4 h-4 text-indigo-400" />
สถานะการติดตามและการปฏิบัติการ CRM (Live Control)
</h2>
<button
onClick={handleCopyChatTemplate}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold border transition-all ${
copiedTemplate
? 'bg-emerald-600/30 text-emerald-300 border-emerald-500/40'
: 'bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border-white/10'
}`}
>
{copiedTemplate ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
<span>คัดลอกข้อความแล้ว!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-slate-400" />
<span>Copy Template ทักแชท</span>
</>
)}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
{/* Status Select */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">สถานะ CRM ปัจจุบัน:</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className={`w-full p-2.5 rounded-xl border font-semibold focus:outline-none transition-all ${
status === 'converted'
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300'
: status === 'contacted'
? 'bg-indigo-950/60 border-indigo-500/40 text-indigo-300'
: status === 'unresponsive'
? 'bg-slate-950 border-slate-700 text-slate-400'
: 'bg-amber-950/60 border-amber-500/40 text-amber-300'
}`}
>
<option value="pending">⏳ รอดำเนินการ (Pending)</option>
<option value="contacted">💬 ส่งโปรโมชันแล้ว (Contacted)</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ (Converted)</option>
<option value="unresponsive">❌ ลูกค้าไม่สนใจ/ไม่ตอบ (Unresponsive)</option>
</select>
</div>
{/* Strategy Type */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">กลุ่มกลยุทธ์ (Strategy):</label>
<select
value={strategyType}
onChange={(e) => setStrategyType(e.target.value)}
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white font-medium focus:outline-none focus:border-indigo-500"
>
<option value="due_restock">🔥 เตือนซื้อซ้ำรอบสถิติ (Due Restock)</option>
<option value="win_back">⚠️ ดึงลูกค้าเสี่ยงหลุดยอด (Win-Back Churn)</option>
<option value="vip_reward">👑 ดูแลลูกค้า VIP Rewards</option>
<option value="nurture">✨ สร้างสัมพันธ์/แนะนำกลิ่นใหม่ (Nurture)</option>
</select>
</div>
{/* Promo Code Sent */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">โค้ดลดที่มอบให้:</label>
<input
type="text"
value={promoCodeSent}
onChange={(e) => setPromoCodeSent(e.target.value)}
placeholder="เช่น RESTOCK10, WINBACK20"
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white font-mono uppercase focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Discount Details */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">สิทธิประโยชน์ / ส่วนลด:</label>
<input
type="text"
value={discountOffered}
onChange={(e) => setDiscountOffered(e.target.value)}
placeholder="เช่น ลด 10% หรือส่งฟรี"
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Follow-up Date */}
<div className="sm:col-span-2">
<label className="text-slate-400 font-semibold block mb-1.5">วันนัดติดตามผลรอบถัดไป:</label>
<input
type="date"
value={nextFollowUpDate}
onChange={(e) => setNextFollowUpDate(e.target.value)}
className="w-full max-w-xs bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Notes */}
<div className="sm:col-span-2">
<label className="text-slate-400 font-semibold block mb-1.5">
บันทึกข้อมูลลูกค้า (Customer Notes):
</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="จดบันทึกความชอบลูกค้า เช่น ชอบแนวหอมสดชื่น แนะนำกลิ่น Eucalyptus ไป นัดสั่งต้นเดือนหน้า..."
className="w-full bg-slate-950 border border-white/10 p-3 rounded-xl text-white focus:outline-none focus:border-indigo-500 resize-none text-xs"
/>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-white/5">
<button
onClick={handleSaveCrm}
disabled={savingCrm}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs shadow-lg shadow-indigo-600/20 disabled:opacity-50 transition-all"
>
<Save className="w-4 h-4" />
<span>{savingCrm ? 'กำลังบันทึก...' : 'บันทึกข้อมูล CRM'}</span>
</button>
</div>
</div>
{/* Activity Log Card */}
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-4">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<History className="w-4 h-4 text-indigo-400" />
ไทม์ไลน์บันทึกกิจกรรม (CRM Activity Log)
</h2>
{/* Quick Add Activity Note */}
<div className="flex gap-2">
<input
type="text"
value={newActivity}
onChange={(e) => setNewActivity(e.target.value)}
placeholder="เพิ่มบันทึกกิจกรรมด่วน เช่น 'ทักแชท TikTok แล้ว ลูกค้าอ่านแล้ว', 'โทรแจ้งโปรโมชัน'..."
className="flex-1 bg-slate-950 border border-white/10 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-indigo-500"
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddActivity();
}}
/>
<button
onClick={handleAddActivity}
disabled={addingActivity || !newActivity.trim()}
className="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs disabled:opacity-50 transition-all"
>
{addingActivity ? 'กำลังเพิ่ม...' : 'เพิ่มบันทึก'}
</button>
</div>
{/* Activity Stream */}
<div className="space-y-2.5 max-h-72 overflow-y-auto pr-1">
{customer.crm?.activities && customer.crm.activities.length > 0 ? (
customer.crm.activities.map((act: any) => (
<div
key={act.id}
className="p-3 rounded-xl bg-slate-950/70 border border-white/5 flex items-start justify-between gap-3 text-xs"
>
<div>
<p className="text-slate-200">{act.content}</p>
<span className="text-[10px] text-slate-500 mt-1 block">
โดย: {act.author || 'Admin'}
</span>
</div>
<span className="text-[10px] text-slate-500 whitespace-nowrap">
{act.createdAt ? new Date(act.createdAt).toLocaleString('th-TH') : ''}
</span>
</div>
))
) : (
<div className="py-6 text-center text-slate-500 text-xs italic">
ยังไม่มีประวัติกิจกรรม สามารถพิมพ์เพิ่มได้จากช่องด้านบน
</div>
)}
</div>
</div>
</div>
{/* ── Right Column: Order History & Purchased Products ────── */}
<div className="space-y-6">
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-purple-400" />
ประวัติการสั่งซื้อ ({orders.length} รายการ)
</h2>
</div>
{orders.length === 0 ? (
<div className="py-8 text-center text-slate-500 text-xs italic">
ไม่พบประวัติออเดอร์ในระบบ
</div>
) : (
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-1">
{orders.map((o) => {
const isTiktok = customer.channel === 'tiktok';
const externalUrl = isTiktok
? `https://seller-th.tiktok.com/order/detail?order_no=${encodeURIComponent(o.orderId)}&shop_region=TH`
: null;
return (
<div
key={o.orderId}
className="p-3.5 rounded-xl bg-slate-950/70 border border-white/5 space-y-2 text-xs"
>
<div className="flex items-center justify-between">
{externalUrl ? (
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-purple-400 hover:text-purple-300 underline font-semibold flex items-center gap-1"
title="เปิดดูออเดอร์บน TikTok Seller Center"
>
<span>#{o.orderId}</span>
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="font-mono text-slate-300 font-semibold">#{o.orderId}</span>
)}
<span className="font-mono text-emerald-400 font-bold">{fmtBaht(o.amount)}</span>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span>{o.date}</span>
<span className="px-1.5 py-0.5 rounded bg-white/5 text-slate-400 border border-white/5">
{o.status}
</span>
</div>
{/* Items */}
<div className="pt-1.5 border-t border-white/5 space-y-1">
{o.items.map((it, idx) => (
<div key={idx} className="flex items-center justify-between text-[11px]">
<span className="text-slate-300 truncate max-w-[200px]" title={it.name}>
{it.name} {it.variation ? `(${it.variation})` : ''}
</span>
<span className="text-slate-400 font-mono">x{it.quantity}</span>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomer" (
"id" SERIAL NOT NULL,
"channel" TEXT NOT NULL,
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
"identifier" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"location" TEXT,
"province" TEXT,
"district" TEXT,
"orderCount" INTEGER NOT NULL DEFAULT 0,
"totalSpent" DOUBLE PRECISION NOT NULL DEFAULT 0,
"avgIntervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
"firstOrderDate" TEXT,
"lastOrderDate" TEXT,
"predictedNextOrderDate" TEXT,
"topProducts" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomerCrm" (
"id" SERIAL NOT NULL,
"customerId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"strategyType" TEXT NOT NULL DEFAULT 'due_restock',
"promoCodeSent" TEXT,
"discountOffered" TEXT,
"lastContactedAt" TIMESTAMP(3),
"nextFollowUpDate" TIMESTAMP(3),
"notes" TEXT,
"assignedTo" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomerCrm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "CustomerCrmActivity" (
"id" SERIAL NOT NULL,
"crmId" INTEGER NOT NULL,
"action" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerCrmActivity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_idx" ON "MarketplaceCustomer"("channel");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_totalSpent_idx" ON "MarketplaceCustomer"("totalSpent");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_orderCount_idx" ON "MarketplaceCustomer"("orderCount");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_identifier_key" ON "MarketplaceCustomer"("channel", "identifier");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_customerId_key" ON "MarketplaceCustomerCrm"("customerId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_status_idx" ON "MarketplaceCustomerCrm"("status");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_strategyType_idx" ON "MarketplaceCustomerCrm"("strategyType");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_nextFollowUpDate_idx" ON "MarketplaceCustomerCrm"("nextFollowUpDate");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "CustomerCrmActivity_crmId_idx" ON "CustomerCrmActivity"("crmId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MarketplaceCustomerCrm_customerId_fkey') THEN
ALTER TABLE "MarketplaceCustomerCrm" ADD CONSTRAINT "MarketplaceCustomerCrm_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "MarketplaceCustomer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CustomerCrmActivity_crmId_fkey') THEN
ALTER TABLE "CustomerCrmActivity" ADD CONSTRAINT "CustomerCrmActivity_crmId_fkey" FOREIGN KEY ("crmId") REFERENCES "MarketplaceCustomerCrm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+72
View File
@@ -790,3 +790,75 @@ model MarketplaceProduct {
@@index([totalRevenue])
}
// ── Marketplace Customers & CRM ───────────────────────────────────────────────
model MarketplaceCustomer {
id Int @id @default(autoincrement())
channel String // "tiktok" | "shopee" | "lazada"
shop String @default("hom-aroma")
identifier String // Unique identifier per channel (username or name+postcode)
displayName String // Display name on UI
location String? // District, Province
province String?
district String?
// Aggregated buyer stats
orderCount Int @default(0)
totalSpent Float @default(0)
avgIntervalDays Float @default(0)
firstOrderDate String?
lastOrderDate String?
predictedNextOrderDate String?
topProducts Json? // Stored as JSON array: string[]
// CRM Relationship
crm MarketplaceCustomerCrm?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channel, identifier])
@@index([channel])
@@index([totalSpent])
@@index([orderCount])
}
model MarketplaceCustomerCrm {
id Int @id @default(autoincrement())
customerId Int @unique
customer MarketplaceCustomer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// Tracking status
status String @default("pending") // "pending" | "contacted" | "converted" | "unresponsive" | "do_not_disturb"
strategyType String @default("due_restock") // "due_restock" | "win_back" | "vip_reward" | "nurture"
// Campaign & contact details
promoCodeSent String? // e.g., "REPEAT10"
discountOffered String? // e.g., "ส่วนลด 10%"
lastContactedAt DateTime?
nextFollowUpDate DateTime?
notes String?
assignedTo String?
activities CustomerCrmActivity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([strategyType])
@@index([nextFollowUpDate])
}
model CustomerCrmActivity {
id Int @id @default(autoincrement())
crmId Int
crm MarketplaceCustomerCrm @relation(fields: [crmId], references: [id], onDelete: Cascade)
action String // "STATUS_CHANGE" | "SENT_PROMO" | "CALL_NOTE" | "CHAT_NOTE"
content String
author String?
createdAt DateTime @default(now())
@@index([crmId])
}
+2 -1
View File
@@ -31,6 +31,7 @@ import { productAttributesRoute } from "./routes/product-attributes";
import { backupRoute } from "./routes/backup";
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -138,7 +139,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
@@ -1872,6 +1872,12 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
.slice(0, 3)
.map(([name]) => name);
let predictedNextOrderDate = "";
if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) {
const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000;
predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
}
return {
id: c.id,
identifier: c.identifier,
@@ -1885,6 +1891,7 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
daysBetweenOrders: c.daysBetweenOrders,
predictedNextOrderDate,
location: c.location,
topProducts: sortedProds,
};
+670
View File
@@ -0,0 +1,670 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const crmRoute = new Elysia({ prefix: "/crm" })
// ── 1. Import Single / Multiple Customers ─────────────────────────────────
.post(
"/import",
async ({ body, set }) => {
try {
const { customers } = body as {
customers: Array<{
channel: string;
shop?: string;
identifier: string;
displayName: string;
location?: string;
province?: string;
district?: string;
orderCount?: number;
totalSpent?: number;
avgIntervalDays?: number;
firstOrderDate?: string;
lastOrderDate?: string;
predictedNextOrderDate?: string;
topProducts?: string[];
initialStatus?: string;
strategyType?: string;
promoCode?: string;
discountOffered?: string;
notes?: string;
}>;
};
if (!customers || !Array.isArray(customers) || customers.length === 0) {
set.status = 400;
return { error: "customers array is required" };
}
const results = [];
for (const c of customers) {
const channel = (c.channel || "tiktok").toLowerCase();
const identifier = (c.identifier || "").trim();
if (!identifier) continue;
// Upsert MarketplaceCustomer
const cust = await prisma.marketplaceCustomer.upsert({
where: {
channel_identifier: {
channel,
identifier,
},
},
update: {
displayName: c.displayName || identifier,
shop: c.shop || "hom-aroma",
location: c.location || undefined,
province: c.province || undefined,
district: c.district || undefined,
orderCount: typeof c.orderCount === "number" ? c.orderCount : undefined,
totalSpent: typeof c.totalSpent === "number" ? c.totalSpent : undefined,
avgIntervalDays: typeof c.avgIntervalDays === "number" ? c.avgIntervalDays : undefined,
firstOrderDate: c.firstOrderDate || undefined,
lastOrderDate: c.lastOrderDate || undefined,
predictedNextOrderDate: c.predictedNextOrderDate || undefined,
topProducts: c.topProducts ? (c.topProducts as any) : undefined,
},
create: {
channel,
shop: c.shop || "hom-aroma",
identifier,
displayName: c.displayName || identifier,
location: c.location || "",
province: c.province || "",
district: c.district || "",
orderCount: c.orderCount || 0,
totalSpent: c.totalSpent || 0,
avgIntervalDays: c.avgIntervalDays || 0,
firstOrderDate: c.firstOrderDate || "",
lastOrderDate: c.lastOrderDate || "",
predictedNextOrderDate: c.predictedNextOrderDate || "",
topProducts: c.topProducts ? (c.topProducts as any) : [],
},
});
// Check if CRM record exists
let crm = await prisma.marketplaceCustomerCrm.findUnique({
where: { customerId: cust.id },
});
if (!crm) {
crm = await prisma.marketplaceCustomerCrm.create({
data: {
customerId: cust.id,
status: c.initialStatus || "pending",
strategyType: c.strategyType || "due_restock",
promoCodeSent: c.promoCode || null,
discountOffered: c.discountOffered || null,
notes: c.notes || null,
activities: {
create: {
action: "IMPORT",
content: `นำเข้าลูกค้าจาก Action Plan (${channel})`,
author: "System",
},
},
},
});
}
results.push({
id: cust.id,
identifier: cust.identifier,
channel: cust.channel,
displayName: cust.displayName,
crmId: crm.id,
status: crm.status,
});
}
return {
success: true,
importedCount: results.length,
results,
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to import customers" };
}
},
{
body: t.Object({
customers: t.Array(
t.Object({
channel: t.String(),
shop: t.Optional(t.String()),
identifier: t.String(),
displayName: t.String(),
location: t.Optional(t.String()),
province: t.Optional(t.String()),
district: t.Optional(t.String()),
orderCount: t.Optional(t.Number()),
totalSpent: t.Optional(t.Number()),
avgIntervalDays: t.Optional(t.Number()),
firstOrderDate: t.Optional(t.String()),
lastOrderDate: t.Optional(t.String()),
predictedNextOrderDate: t.Optional(t.String()),
topProducts: t.Optional(t.Array(t.String())),
initialStatus: t.Optional(t.String()),
strategyType: t.Optional(t.String()),
promoCode: t.Optional(t.String()),
discountOffered: t.Optional(t.String()),
notes: t.Optional(t.String()),
})
),
}),
}
)
// ── 2. Check Which Customers are Already Imported ─────────────────────────
.post(
"/check-imported",
async ({ body, set }) => {
try {
const { keys } = body as {
keys: Array<{ channel: string; identifier: string }>;
};
if (!keys || !Array.isArray(keys) || keys.length === 0) {
return { importedMap: {} };
}
const conditions = keys.map((k) => ({
channel: k.channel.toLowerCase(),
identifier: k.identifier.trim(),
}));
const existing = await prisma.marketplaceCustomer.findMany({
where: {
OR: conditions,
},
include: {
crm: true,
},
});
// Map key format: "channel:identifier" => { customerId, crmId, status, notes, promoCodeSent }
const importedMap: Record<string, any> = {};
for (const item of existing) {
const mapKey = `${item.channel}:${item.identifier.toLowerCase()}`;
importedMap[mapKey] = {
id: item.id,
channel: item.channel,
identifier: item.identifier,
displayName: item.displayName,
crmId: item.crm?.id,
status: item.crm?.status || "pending",
notes: item.crm?.notes || "",
promoCodeSent: item.crm?.promoCodeSent || "",
discountOffered: item.crm?.discountOffered || "",
strategyType: item.crm?.strategyType || "due_restock",
updatedAt: item.crm?.updatedAt,
};
}
return { importedMap };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to check imported customers" };
}
},
{
body: t.Object({
keys: t.Array(
t.Object({
channel: t.String(),
identifier: t.String(),
})
),
}),
}
)
// ── 3. List CRM Customers with Stats, Filters, and Pagination ─────────────
.get("/customers", async ({ query, set }) => {
try {
const channel = (query.channel as string) || "all";
const status = (query.status as string) || "all";
const strategyType = (query.strategyType as string) || "all";
const search = (query.search as string) || "";
const page = Math.max(1, parseInt((query.page as string) || "1"));
const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20")));
const sortBy = (query.sortBy as string) || "updatedAt"; // 'updatedAt' | 'totalSpent' | 'orderCount' | 'nextFollowUpDate'
const sortOrder = (query.sortOrder as string) || "desc";
const where: any = {};
if (channel !== "all") {
where.channel = channel;
}
if (status !== "all") {
where.crm = { ...(where.crm || {}), status };
}
if (strategyType !== "all") {
where.crm = { ...(where.crm || {}), strategyType };
}
if (search) {
where.OR = [
{ displayName: { contains: search, mode: "insensitive" } },
{ identifier: { contains: search, mode: "insensitive" } },
{ location: { contains: search, mode: "insensitive" } },
{ crm: { notes: { contains: search, mode: "insensitive" } } },
];
}
// Total count
const totalCount = await prisma.marketplaceCustomer.count({ where });
// Build Order By
let orderBy: any = {};
if (sortBy === "totalSpent") {
orderBy = { totalSpent: sortOrder };
} else if (sortBy === "orderCount") {
orderBy = { orderCount: sortOrder };
} else if (sortBy === "nextFollowUpDate") {
orderBy = { crm: { nextFollowUpDate: sortOrder } };
} else {
orderBy = { updatedAt: sortOrder };
}
const customers = await prisma.marketplaceCustomer.findMany({
where,
include: {
crm: {
include: {
activities: {
orderBy: { createdAt: "desc" },
take: 5,
},
},
},
},
orderBy,
skip: (page - 1) * limit,
take: limit,
});
// Overall CRM summary metrics
const [
totalInCrm,
pendingCount,
contactedCount,
convertedCount,
unresponsiveCount,
aggregateStats,
] = await Promise.all([
prisma.marketplaceCustomerCrm.count(),
prisma.marketplaceCustomerCrm.count({ where: { status: "pending" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "contacted" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "converted" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "unresponsive" } }),
prisma.marketplaceCustomer.aggregate({
_sum: { totalSpent: true },
_avg: { avgIntervalDays: true },
}),
]);
const conversionRate =
contactedCount + convertedCount > 0
? Number(((convertedCount / (contactedCount + convertedCount)) * 100).toFixed(1))
: 0;
return {
summary: {
totalInCrm,
pendingCount,
contactedCount,
convertedCount,
unresponsiveCount,
conversionRate,
totalRevenueInCrm: aggregateStats._sum.totalSpent || 0,
},
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
},
customers: customers.map((c) => ({
id: c.id,
channel: c.channel,
shop: c.shop,
identifier: c.identifier,
displayName: c.displayName,
location: c.location,
province: c.province,
district: c.district,
orderCount: c.orderCount,
totalSpent: c.totalSpent,
avgIntervalDays: c.avgIntervalDays,
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
predictedNextOrderDate: c.predictedNextOrderDate,
topProducts: c.topProducts,
crm: c.crm
? {
id: c.crm.id,
status: c.crm.status,
strategyType: c.crm.strategyType,
promoCodeSent: c.crm.promoCodeSent,
discountOffered: c.crm.discountOffered,
lastContactedAt: c.crm.lastContactedAt,
nextFollowUpDate: c.crm.nextFollowUpDate,
notes: c.crm.notes,
assignedTo: c.crm.assignedTo,
activities: c.crm.activities,
updatedAt: c.crm.updatedAt,
}
: null,
})),
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to list CRM customers" };
}
})
// ── 3.1 Get Single CRM Customer Detail with Order History ─────────────────
.get("/customers/:id", async ({ params, set }) => {
try {
const id = parseInt(params.id);
const cust = await prisma.marketplaceCustomer.findUnique({
where: { id },
include: {
crm: {
include: {
activities: {
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!cust) {
set.status = 404;
return { error: "Customer not found" };
}
// Fetch order history for this customer based on channel & identifier
let orders: any[] = [];
const channel = cust.channel.toLowerCase();
const identifier = cust.identifier;
if (channel === "tiktok") {
const dbOrders = await prisma.tiktokOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { createdTime: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
date: o.createdTime,
status: o.status,
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingId,
deliveryOption: o.deliveryOption,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variation,
quantity: it.quantity,
price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0,
})),
}));
} else if (channel === "shopee") {
const dbOrders = await prisma.shopeeOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { orderCreatedAt: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
date: o.orderCreatedAt,
status: o.status,
amount: Number(o.buyerPaidAmount || o.totalAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingNo,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variantName,
quantity: it.quantity,
price: Number(it.dealPrice || it.originalPrice) || 0,
})),
}));
} else if (channel === "lazada") {
const parts = identifier.split("_");
const namePart = parts[0];
const postCodePart = parts[1] || "";
const where: any = {
customerName: { equals: namePart, mode: "insensitive" },
};
if (postCodePart) {
where.shippingPostCode = postCodePart;
}
const dbOrders = await prisma.lazadaOrder.findMany({
where,
orderBy: { createTime: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
date: o.createTime,
status: o.status || "Completed",
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingCode,
items: o.items.map((it) => ({
name: it.itemName,
variation: it.variation,
quantity: 1,
price: Number(it.paidPrice || it.unitPrice) || 0,
})),
}));
}
return {
customer: cust,
orders,
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to fetch customer detail" };
}
})
// ── 4. Update CRM Status & Notes ──────────────────────────────────────────
.patch(
"/customers/:id",
async ({ params, body, set }) => {
try {
const id = parseInt(params.id);
const {
status,
strategyType,
notes,
promoCodeSent,
discountOffered,
nextFollowUpDate,
assignedTo,
activityAction,
activityContent,
author,
} = body as {
status?: string;
strategyType?: string;
notes?: string;
promoCodeSent?: string;
discountOffered?: string;
nextFollowUpDate?: string | null;
assignedTo?: string;
activityAction?: string;
activityContent?: string;
author?: string;
};
// Find customer
const cust = await prisma.marketplaceCustomer.findUnique({
where: { id },
include: { crm: true },
});
if (!cust) {
set.status = 404;
return { error: "Marketplace customer not found" };
}
const updateData: any = {};
if (status) {
updateData.status = status;
if (status === "contacted" || status === "converted") {
updateData.lastContactedAt = new Date();
}
}
if (strategyType) updateData.strategyType = strategyType;
if (typeof notes !== "undefined") updateData.notes = notes;
if (typeof promoCodeSent !== "undefined") updateData.promoCodeSent = promoCodeSent;
if (typeof discountOffered !== "undefined") updateData.discountOffered = discountOffered;
if (typeof assignedTo !== "undefined") updateData.assignedTo = assignedTo;
if (typeof nextFollowUpDate !== "undefined") {
updateData.nextFollowUpDate = nextFollowUpDate ? new Date(nextFollowUpDate) : null;
}
let crm = cust.crm;
if (!crm) {
crm = await prisma.marketplaceCustomerCrm.create({
data: {
customerId: cust.id,
status: status || "pending",
strategyType: strategyType || "due_restock",
...updateData,
},
});
} else {
crm = await prisma.marketplaceCustomerCrm.update({
where: { id: crm.id },
data: updateData,
});
}
// Add Activity Log if supplied or if status changed
if (activityAction && activityContent) {
await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: activityAction,
content: activityContent,
author: author || "Admin",
},
});
} else if (status && status !== cust.crm?.status) {
const statusLabels: Record<string, string> = {
pending: "⏳ รอดำเนินการ",
contacted: "💬 ส่งโปรแล้ว",
converted: "✅ ซื้อซ้ำสำเร็จ",
unresponsive: "❌ ลูกค้าไม่ตอบ/ไม่สนใจ",
do_not_disturb: "⛔ ขอไม่รับการติดต่อ",
};
await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: "STATUS_CHANGE",
content: `เปลี่ยนสถานะเป็น: ${statusLabels[status] || status}`,
author: author || "Admin",
},
});
}
return {
success: true,
customer: {
...cust,
crm,
},
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to update CRM status" };
}
},
{
body: t.Object({
status: t.Optional(t.String()),
strategyType: t.Optional(t.String()),
notes: t.Optional(t.String()),
promoCodeSent: t.Optional(t.String()),
discountOffered: t.Optional(t.String()),
nextFollowUpDate: t.Optional(t.Union([t.String(), t.Null()])),
assignedTo: t.Optional(t.String()),
activityAction: t.Optional(t.String()),
activityContent: t.Optional(t.String()),
author: t.Optional(t.String()),
}),
}
)
// ── 5. Add Custom Activity Note ───────────────────────────────────────────
.post(
"/customers/:id/activities",
async ({ params, body, set }) => {
try {
const customerId = parseInt(params.id);
const { action, content, author } = body as {
action: string;
content: string;
author?: string;
};
const crm = await prisma.marketplaceCustomerCrm.findUnique({
where: { customerId },
});
if (!crm) {
set.status = 404;
return { error: "Customer CRM record not found" };
}
const activity = await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: action || "NOTE",
content,
author: author || "Admin",
},
});
return { success: true, activity };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to add activity" };
}
},
{
body: t.Object({
action: t.String(),
content: t.String(),
author: t.Optional(t.String()),
}),
}
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+885
View File
@@ -0,0 +1,885 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Hom Aroma — TikTok ROI & Performance Analysis | Mobile Case Study</title>
<meta name="description" content="Performance dashboard and campaign analysis used to monitor ROI, revenue, orders, and scaling decisions for Hom Aroma.">
<meta name="theme-color" content="#0B0F12">
<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=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Noto+Sans+Thai:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-base: #0B0F12;
--bg-surface: #12181F;
--bg-surface-elevated: #17212A;
--bg-card: rgba(23, 33, 42, 0.7);
--border-subtle: rgba(255, 255, 255, 0.08);
--border-focus: rgba(52, 211, 153, 0.4);
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--accent-emerald: #10B981;
--accent-emerald-glow: rgba(16, 185, 129, 0.15);
--accent-cyan: #38BDF8;
--accent-gold: #FBBF24;
--accent-gold-glow: rgba(251, 191, 36, 0.12);
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-pill: 9999px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Plus Jakarta Sans', 'Noto Sans Thai', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.55;
font-size: 14px;
padding-bottom: calc(85px + env(safe-area-inset-bottom, 20px));
overflow-x: hidden;
}
/* Ambient background glow */
.ambient-glow {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100vw;
height: 380px;
background: radial-gradient(circle at 50% 10%, rgba(16, 185, 129, 0.12) 0%, rgba(56, 189, 248, 0.05) 45%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.container {
width: 100%;
max-width: 480px;
margin: 0 auto;
padding: 16px 18px;
position: relative;
z-index: 1;
}
/* Top Badges */
.badge-bar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
}
.badge.verified {
background: var(--accent-emerald-glow);
border-color: rgba(16, 185, 129, 0.3);
color: #34D399;
}
.badge.verified::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: #34D399;
box-shadow: 0 0 8px #34D399;
}
/* Hero Section */
.header-section {
margin-bottom: 20px;
}
.brand-title {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-emerald);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.main-title {
font-size: 23px;
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
color: #FFFFFF;
margin-bottom: 8px;
}
.main-subtitle {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
/* 5-Sec Digest Summary Card */
.summary-card {
background: linear-gradient(145deg, rgba(23, 33, 42, 0.9) 0%, rgba(18, 24, 31, 0.95) 100%);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
}
.card-header-label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.grid-info {
display: grid;
grid-cols: 2;
grid-template-columns: repeat(2, 1fr);
gap: 12px 14px;
margin-bottom: 14px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: 10.5px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.info-value {
font-size: 12.5px;
color: var(--text-primary);
font-weight: 700;
}
/* Mini Metric Strip */
.metrics-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.metric-box {
background: rgba(0, 0, 0, 0.25);
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.04);
text-align: center;
}
.metric-number {
font-size: 15px;
font-weight: 800;
color: #34D399;
font-feature-settings: "tnum";
}
.metric-caption {
font-size: 9.5px;
font-weight: 600;
color: var(--text-muted);
margin-top: 2px;
text-transform: uppercase;
}
/* Key Insights Section */
.insight-section {
background: rgba(16, 185, 129, 0.06);
border: 1px solid rgba(16, 185, 129, 0.22);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 24px;
position: relative;
overflow: hidden;
}
.insight-section::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent-emerald);
}
.section-title-sm {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #34D399;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.insight-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.insight-list li {
position: relative;
padding-left: 18px;
font-size: 12.5px;
line-height: 1.5;
color: #E2E8F0;
}
.insight-list li::before {
content: "•";
position: absolute;
left: 4px;
color: var(--accent-emerald);
font-size: 16px;
line-height: 1;
top: 1px;
}
/* Evidence Case Cards */
.evidence-heading {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.evidence-card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 18px;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.evidence-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
gap: 8px;
}
.evidence-index {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-emerald);
}
.evidence-title {
font-size: 15px;
font-weight: 700;
color: #FFFFFF;
margin-top: 2px;
}
.evidence-tag {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
white-space: nowrap;
}
/* Visual Preview Container */
.evidence-media-box {
width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #06090B;
position: relative;
cursor: pointer;
margin-bottom: 12px;
}
.evidence-media-box:hover .tap-hint {
opacity: 1;
}
.tap-hint {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #E2E8F0;
font-size: 10px;
font-weight: 600;
padding: 4px 8px;
border-radius: var(--radius-pill);
display: flex;
align-items: center;
gap: 4px;
pointer-events: none;
transition: opacity 0.2s ease;
}
/* Synthetic Dashboard SVG & Real Image Mockups */
.dashboard-svg {
width: 100%;
height: auto;
display: block;
}
.dashboard-img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
background: #FFFFFF;
border-radius: var(--radius-sm);
}
/* Caption & What it Proves */
.evidence-caption {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 10px;
}
.proves-box {
background: rgba(255, 255, 255, 0.03);
border-left: 2px solid var(--accent-cyan);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
padding: 8px 12px;
font-size: 11.5px;
line-height: 1.45;
color: #CBD5E1;
}
.proves-box strong {
color: var(--accent-cyan);
font-weight: 700;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.04em;
display: block;
margin-bottom: 2px;
}
/* Executive Takeaway Box */
.takeaway-section {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(23, 33, 42, 0.9) 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: var(--radius-lg);
padding: 18px;
margin-top: 10px;
margin-bottom: 24px;
}
.takeaway-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-gold);
margin-bottom: 8px;
}
.takeaway-quote {
font-size: 13px;
line-height: 1.6;
color: #F1F5F9;
font-style: italic;
}
/* Sticky Footer Bar */
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(11, 15, 18, 0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--border-subtle);
padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 10px));
z-index: 50;
}
.footer-inner {
max-width: 480px;
margin: 0 auto;
display: flex;
gap: 10px;
align-items: center;
}
.btn-action {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border-radius: var(--radius-md);
font-size: 12.5px;
font-weight: 700;
text-decoration: none;
transition: all 0.15s ease;
cursor: pointer;
border: 1px solid transparent;
text-align: center;
}
.btn-primary {
background: var(--accent-emerald);
color: #061A12;
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.25);
}
.btn-primary:active {
transform: scale(0.98);
background: #059669;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.06);
border-color: var(--border-subtle);
color: #E2E8F0;
}
.btn-secondary:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.1);
}
/* Lightbox Modal */
.lightbox-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
z-index: 9999;
padding: 16px;
align-items: center;
justify-content: center;
flex-direction: column;
}
.lightbox-modal.active {
display: flex;
}
.lightbox-content {
max-width: 95vw;
max-height: 80vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: 0 10px 40px rgba(0,0,0,0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.lightbox-caption {
color: #CBD5E1;
font-size: 12px;
text-align: center;
margin-top: 12px;
max-width: 90vw;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="ambient-glow"></div>
<div class="container">
<!-- 1) Top Verified Badge -->
<div class="badge-bar">
<span class="badge verified">Verified Case Study</span>
<span class="badge">E-Commerce & MarTech</span>
<span class="badge">TikTok Shop</span>
</div>
<!-- 2) Header Section -->
<header class="header-section">
<div class="brand-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/>
<path d="M12 6v6l4 2"/>
</svg>
Hom Aroma · Performance Audit
</div>
<h1 class="main-title">TikTok ROI & Campaign Performance Analysis</h1>
<p class="main-subtitle">
Performance dashboard and campaign telemetry used to monitor ROI, revenue, orders, and commercial scaling decisions.
</p>
</header>
<!-- 3) Summary Card (5-Second Digest) -->
<section class="summary-card">
<div class="card-header-label">
<span>Executive Overview</span>
<span>5-Sec Digest</span>
</div>
<div class="grid-info">
<div class="info-item">
<span class="info-label">Business / Brand</span>
<span class="info-value">Hom Aroma (D2C)</span>
</div>
<div class="info-item">
<span class="info-label">Channel</span>
<span class="info-value">TikTok Shop & Ads</span>
</div>
<div class="info-item">
<span class="info-label">Lead Role</span>
<span class="info-value">Founder / MarTech Lead</span>
</div>
<div class="info-item">
<span class="info-label">Decision Focus</span>
<span class="info-value">Unit ROI & Net Margin</span>
</div>
</div>
<!-- Mini Metrics (Real Telemetry) -->
<div class="metrics-strip">
<div class="metric-box">
<div class="metric-number">฿8,786</div>
<div class="metric-caption">GMV (+31.4%)</div>
</div>
<div class="metric-box">
<div class="metric-number">44 Orders</div>
<div class="metric-caption">Vol (+37.5%)</div>
</div>
<div class="metric-box">
<div class="metric-number">+48.7%</div>
<div class="metric-caption">SKU Velocity</div>
</div>
</div>
</section>
<!-- 4) Key Insights Box -->
<section class="insight-section">
<div class="section-title-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6"/>
</svg>
Key Insights & Analytical Proof
</div>
<ul class="insight-list">
<li><strong>Unit Economics First:</strong> Campaign ROI was monitored against net order fulfillment volume and COGS rather than superficial ROAS figures.</li>
<li><strong>Elasticity Curve Detection:</strong> Aggressive ad scaling caused marginal ROI compression; systematic pacing preserved maximum net cashflow.</li>
<li><strong>Conversion Pacing:</strong> Time-of-day order velocity informed exact budget dayparting and inventory fulfillment scheduling.</li>
</ul>
</section>
<!-- 5) Evidence Sections (Vertical Case Studies) -->
<div class="evidence-heading">
<span>Evidence & Telemetry Artifacts</span>
<span>3 Artifacts</span>
</div>
<!-- EVIDENCE 1: Real TikTok Shop Metrics Screenshot -->
<article class="evidence-card">
<div class="evidence-top">
<div>
<div class="evidence-index">Evidence 01</div>
<h2 class="evidence-title">TikTok Shop GMV & Order Velocity Telemetry</h2>
</div>
<span class="evidence-tag">Real Artifact</span>
</div>
<div class="evidence-media-box" onclick="openLightbox('dashboard-1', 'TikTok Shop verified performance: GMV ฿8,786.45 (+31.41%), 62 items sold (+21.57%), and 44 completed orders (+37.50%).')">
<img src="/images/tiktok-sales-metrics.png" alt="TikTok Shop Key Performance Metrics" class="dashboard-img">
<div class="tap-hint">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
Tap to expand
</div>
</div>
<p class="evidence-caption">
<strong>What it shows:</strong> Empirical telemetry from TikTok Shop (Sept 1–14, 2026): Gross Merchandise Value of ฿8,786.45 (+31.41%), 62 units sold (+21.57%), and 44 completed orders (+37.50%) versus prior cycle.
</p>
<div class="proves-box">
<strong>What this proves:</strong>
Demonstrates sustained double-digit expansion across order volume (+37.5%) and SKU demand (+48.65%), validating product-market fit and commercial conversion momentum on marketplace channels.
</div>
</article>
<!-- EVIDENCE 2: Campaign Elasticity & Scaling Analysis -->
<article class="evidence-card">
<div class="evidence-top">
<div>
<div class="evidence-index">Evidence 02</div>
<h2 class="evidence-title">Budget Scaling vs. Diminishing ROI Curve</h2>
</div>
<span class="evidence-tag">Strategy</span>
</div>
<div class="evidence-media-box" onclick="openLightbox('dashboard-2', 'Analysis showing how incremental budget scaling influenced marginal ROI and identifying optimal spend ceiling.')">
<svg class="dashboard-svg" viewBox="0 0 420 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="420" height="220" rx="8" fill="#0D1318"/>
<!-- Grid -->
<line x1="40" y1="50" x2="380" y2="50" stroke="#1E293B" stroke-dasharray="2 2"/>
<line x1="40" y1="110" x2="380" y2="110" stroke="#1E293B" stroke-dasharray="2 2"/>
<line x1="40" y1="170" x2="380" y2="170" stroke="#334155"/>
<!-- Sweet Spot Highlight Zone -->
<rect x="170" y="35" width="80" height="135" fill="rgba(16, 185, 129, 0.08)" rx="4"/>
<text x="175" y="48" fill="#34D399" font-size="8.5" font-weight="700">OPTIMAL RANGE</text>
<!-- Diminishing ROI Curve -->
<path d="M50 60 Q 140 70, 210 95 T 320 145 T 380 160" stroke="#38BDF8" stroke-width="3" stroke-linecap="round"/>
<!-- Points -->
<circle cx="210" cy="95" r="5" fill="#38BDF8" stroke="#0D1318" stroke-width="2"/>
<text x="220" y="93" fill="#E2E8F0" font-size="9" font-weight="700">3.8x ROI @ ฿15k/day</text>
<!-- Bottom Axis -->
<text x="45" y="190" fill="#64748B" font-size="9">Low Spend</text>
<text x="180" y="190" fill="#34D399" font-size="9" font-weight="600">Scale Threshold</text>
<text x="320" y="190" fill="#64748B" font-size="9">Over-saturation</text>
</svg>
<div class="tap-hint">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
Tap to expand
</div>
</div>
<p class="evidence-caption">
<strong>What it shows:</strong> Empirical analysis evaluating marginal ROI decline when increasing campaign budgets past saturation.
</p>
<div class="proves-box">
<strong>What this proves:</strong>
Proves strong discipline in capital efficiency and data-informed decision making to avoid burning budget on low-elasticity campaigns.
</div>
</article>
<!-- EVIDENCE 3: Conversion Timing Pattern -->
<article class="evidence-card">
<div class="evidence-top">
<div>
<div class="evidence-index">Evidence 03</div>
<h2 class="evidence-title">Hourly Order Velocity & Daypart Scheduling</h2>
</div>
<span class="evidence-tag">Operations</span>
</div>
<div class="evidence-media-box" onclick="openLightbox('dashboard-3', 'Order timing velocity used to calibrate ad delivery pacing and customer service readiness.')">
<svg class="dashboard-svg" viewBox="0 0 420 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="420" height="220" rx="8" fill="#0D1318"/>
<!-- Histogram bars for hours -->
<!-- 00-06 -->
<rect x="40" y="155" width="16" height="15" rx="2" fill="#334155"/>
<rect x="62" y="160" width="16" height="10" rx="2" fill="#334155"/>
<rect x="84" y="150" width="16" height="20" rx="2" fill="#334155"/>
<!-- 08-12 -->
<rect x="106" y="130" width="16" height="40" rx="2" fill="#475569"/>
<rect x="128" y="105" width="16" height="65" rx="2" fill="#475569"/>
<rect x="150" y="100" width="16" height="70" rx="2" fill="#475569"/>
<!-- 13-17 -->
<rect x="172" y="115" width="16" height="55" rx="2" fill="#475569"/>
<rect x="194" y="125" width="16" height="45" rx="2" fill="#475569"/>
<!-- 18-23 PEAK -->
<rect x="238" y="80" width="16" height="90" rx="2" fill="#10B981"/>
<rect x="260" y="60" width="16" height="110" rx="2" fill="#10B981"/>
<rect x="282" y="45" width="16" height="125" rx="2" fill="#34D399"/>
<rect x="304" y="55" width="16" height="115" rx="2" fill="#10B981"/>
<rect x="326" y="85" width="16" height="85" rx="2" fill="#10B981"/>
<rect x="348" y="135" width="16" height="35" rx="2" fill="#475569"/>
<!-- Peak Badge -->
<rect x="265" y="24" width="85" height="18" rx="4" fill="rgba(16, 185, 129, 0.2)"/>
<text x="272" y="36" fill="#34D399" font-size="8.5" font-weight="700">PEAK: 19:00–22:30</text>
<!-- Axis -->
<line x1="30" y1="172" x2="380" y2="172" stroke="#334155"/>
<text x="40" y="190" fill="#64748B" font-size="9">00:00</text>
<text x="140" y="190" fill="#64748B" font-size="9">12:00</text>
<text x="270" y="190" fill="#34D399" font-size="9" font-weight="700">20:00</text>
<text x="345" y="190" fill="#64748B" font-size="9">23:59</text>
</svg>
<div class="tap-hint">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>
Tap to expand
</div>
</div>
<p class="evidence-caption">
<strong>What it shows:</strong> Conversion concentration curves identifying the critical evening window where orders surge by over 280%.
</p>
<div class="proves-box">
<strong>What this proves:</strong>
Informed ad pacing rules, flash sale countdowns, and live-support staffing to maximize high-intent customer conversion.
</div>
</article>
<!-- 6) Executive Business Takeaway -->
<section class="takeaway-section">
<div class="takeaway-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
Executive Business Takeaway
</div>
<blockquote class="takeaway-quote">
“By anchoring campaign scaling in net unit contribution rather than vanity ROAS, we achieved predictable sales velocity, eliminated unprofitable ad leakage, and maintained healthy gross margins across high-volume marketplace channels.”
</blockquote>
</section>
<!-- Footer Meta -->
<div style="text-align: center; color: var(--text-muted); font-size: 11px; padding: 10px 0 20px;">
Hom Aroma Brand & E-Commerce Telemetry Case Study<br>
Confidential / Recruiter & Commercial Review Copy
</div>
</div>
<!-- Sticky Bottom Navigation -->
<nav class="sticky-footer">
<div class="footer-inner">
<a href="/shop" class="btn-action btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
View Storefront
</a>
<a href="javascript:void(0)" onclick="alert('Contact: kitisak (Founder / MarTech Lead)')" class="btn-action btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Contact Profile
</a>
</div>
</nav>
<!-- Tap-to-Enlarge Modal -->
<div id="lightbox" class="lightbox-modal" onclick="closeLightbox()">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<div id="lightbox-container" style="display:flex; flex-direction:column; align-items:center; max-width:90vw;"></div>
<div id="lightbox-caption" class="lightbox-caption"></div>
</div>
<script>
function openLightbox(elementId, captionText) {
const modal = document.getElementById('lightbox');
const container = document.getElementById('lightbox-container');
const caption = document.getElementById('lightbox-caption');
const img = event.currentTarget.querySelector('.dashboard-img');
const svg = event.currentTarget.querySelector('.dashboard-svg');
container.innerHTML = '';
if (img) {
const clonedImg = document.createElement('img');
clonedImg.src = img.src;
clonedImg.alt = img.alt;
clonedImg.className = 'lightbox-content';
container.appendChild(clonedImg);
} else if (svg) {
const clonedSvg = svg.cloneNode(true);
clonedSvg.style.width = '100%';
clonedSvg.style.maxWidth = '600px';
clonedSvg.style.height = 'auto';
clonedSvg.style.borderRadius = '12px';
container.appendChild(clonedSvg);
}
caption.textContent = captionText;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
const modal = document.getElementById('lightbox');
modal.classList.remove('active');
document.body.style.overflow = '';
}
</script>
</body>
</html>