x
This commit is contained in:
1 parent
d5ea45a536
commit
afd6904c48
20 files changed
+5435
-51
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+90
@@ -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 $$;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 |
@@ -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()">×</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>
|
||||
Reference in new issue
Block a user