diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 1caf605..4e3290c 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -60,6 +60,9 @@ import OmnichannelSalesMapPage from './pages/analytics/OmnichannelSalesMapPage' import ProductSalesMapPage from './pages/analytics/ProductSalesMapPage' import MalaysiaSalesMapPage from './pages/analytics/MalaysiaSalesMapPage' 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 MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage' function App() { @@ -127,6 +130,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> {/* Tools */} } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index f12adb7..e9d8b2d 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { Layers, Globe2, Users, + UserCheck, Image as ImageIcon, MessageSquare, Store, @@ -35,6 +36,8 @@ import { FileJson, FolderArchive, Map, + Calendar, + Clock, } from 'lucide-react' interface SidebarProps { @@ -98,6 +101,9 @@ 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 = [ diff --git a/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx b/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx new file mode 100644 index 0000000..45f0c16 --- /dev/null +++ b/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from 'react'; +import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag } from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import { fmtBaht } 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[]; +} + +interface RepeatCustomerOrdersModalProps { + customer: { + id: string; + identifier: string; + displayName: string; + primaryChannel: 'tiktok' | 'shopee' | 'lazada'; + location?: string; + orderCount?: number; + totalSpent?: number; + }; + onClose: () => void; +} + +export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatCustomerOrdersModalProps) { + const [loading, setLoading] = useState(true); + const [orders, setOrders] = useState([]); + const [customerDetails, setCustomerDetails] = useState(null); + + useEffect(() => { + setLoading(true); + const params = new URLSearchParams(); + params.set('channel', customer.primaryChannel); + params.set('identifier', customer.identifier); + + fetch(`${API_BASE}/business-analytics/repeat-customers/detail?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((res) => res.json()) + .then((data) => { + setOrders(data.orders || []); + setCustomerDetails(data.customer || null); + }) + .catch((err) => console.error('Failed to load customer orders:', err)) + .finally(() => setLoading(false)); + }, [customer]); + + const getChannelBadge = (channel: string) => { + switch (channel) { + case 'tiktok': + return ( + + TikTok Shop + + ); + case 'shopee': + return ( + + Shopee + + ); + case 'lazada': + return ( + + Lazada + + ); + default: + return ( + + {channel} + + ); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+
+

+ {customer.displayName} +

+ {getChannelBadge(customer.primaryChannel)} +
+
+ {customer.location && ( + + + {customer.location} + + )} + + + ยอดสะสม: {fmtBaht(customerDetails?.totalSpent || customer.totalSpent || 0)} + + + + {customerDetails?.totalOrders || customer.orderCount || 0} คำสั่งซื้อ + +
+
+
+ +
+ + {/* Content Body */} +
+ {loading ? ( +
+
+

กำลังโหลดประวัติคำสั่งซื้อ...

+
+ ) : orders.length === 0 ? ( +
+ +

ไม่พบข้อมูลประวัติคำสั่งซื้อ

+

คำสั่งซื้ออาจถูกลบหรือไม่มีข้อมูลรายการสินค้า

+
+ ) : ( +
+
+ ไทม์ไลน์คำสั่งซื้อทั้งหมด ({orders.length} รายการ) + เรียงจากล่าสุดไปอดีต +
+ + {orders.map((order, idx) => ( +
+ {/* Order Top Bar */} +
+
+ + #{orders.length - idx} + + + {order.orderId} + + + {order.shop} + +
+ +
+ + {order.status || 'สำเร็จ'} + + + {fmtBaht(order.amount)} + +
+
+ + {/* Order Meta */} +
+ {order.date && ( + + + {order.date} + + )} + {order.trackingId && ( + + + Tracking: {order.trackingId} + + )} + {order.shippingFee > 0 && ( + + ค่าจัดส่ง: {fmtBaht(order.shippingFee)} + + )} +
+ + {/* Order Items */} + {order.items && order.items.length > 0 && ( +
+ {order.items.map((item, itemIdx) => ( +
+
+ + + {item.name} + + {item.variation && ( + + {item.variation} + + )} +
+
+ + x{item.quantity} + + + {fmtBaht(item.price)} + +
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + คำนวณจากประวัติคำสั่งซื้อในระบบ HomAroma + + +
+
+
+ ); +} diff --git a/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx b/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx new file mode 100644 index 0000000..ab749b4 --- /dev/null +++ b/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx @@ -0,0 +1,58 @@ +import { NavLink } from 'react-router-dom'; +import { Table2, Calendar, Clock } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export default function RepeatCustomersNavTabs() { + const { t } = useTranslation(); + + const TABS = [ + { + to: '/business-analytics/repeat-customers', + label: t('repeatCustomers.tabsTable', 'ตารางภาพรวม (Table)'), + icon: Table2, + end: true, + }, + { + to: '/business-analytics/repeat-customers-calendar', + label: t('repeatCustomers.tabsCalendar', 'ปฏิทินการซื้อซ้ำ (Calendar)'), + icon: Calendar, + badge: 'Heatmap', + }, + { + to: '/business-analytics/repeat-customers-timeline', + label: t('repeatCustomers.tabsTimeline', 'ไทม์ไลน์รอบซื้อ (Timeline)'), + icon: Clock, + badge: 'Predictive', + }, + ]; + + return ( +
+ {TABS.map((tab) => { + const Icon = tab.icon; + return ( + + `flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold whitespace-nowrap transition-all ${ + isActive + ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg shadow-indigo-600/25 border border-white/20' + : 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent' + }` + } + > + + {tab.label} + {tab.badge && ( + + {tab.badge} + + )} + + ); + })} +
+ ); +} diff --git a/apps/admin/src/locales/en/translation.json b/apps/admin/src/locales/en/translation.json index 54249a9..1d7b455 100644 --- a/apps/admin/src/locales/en/translation.json +++ b/apps/admin/src/locales/en/translation.json @@ -60,6 +60,9 @@ "productSalesMap": "Product Sales Map", "malaysiaSalesMap": "Malaysia Map", "vietnamSalesMap": "Vietnam Map", + "repeatCustomers": "Repeat Customers (Table)", + "repeatCustomersCalendar": "Repeat Customers (Calendar)", + "repeatCustomersTimeline": "Repeat Customers (Timeline)", "tools": "Tools", "marketplaceProducts": "Marketplace Products", "tiktok": "TikTok Channel", @@ -1384,6 +1387,33 @@ "CONTACT_INFO": "Beautiful information box showing address, telephone, and social details.", "CONTACT_FORM": "General support email inquiry contact form." } + }, + "repeatCustomers": { + "title": "Repeat Customers", + "subtitle": "Analyze customer retention and repeat purchase behavior across Shopee, Lazada, and TikTok with Repurchase Rate & LTV metrics.", + "exportCsv": "Export CSV", + "allChannels": "All Channels", + "repeatOnly": "Repeat Buyers Only (≥ 2 Orders)", + "allBuyers": "All Buyers (≥ 1 Order)", + "kpiTotalCustomers": "Total Customers", + "kpiRepeatCustomers": "Repeat Customers", + "kpiRepurchaseRate": "Repurchase Rate", + "kpiRepeatRevenue": "Repeat Revenue", + "kpiAvgInterval": "Avg. Repurchase Cycle", + "frequencyDistribution": "Purchase Frequency Distribution", + "topRepeatProducts": "Top Repurchased Products", + "tableTitle": "Repeat Customers & Order History", + "tabsTable": "Table View", + "tabsCalendar": "Calendar View", + "tabsTimeline": "Timeline View" + }, + "repeatCustomersCalendar": { + "title": "Repeat Customers Calendar", + "subtitle": "Track daily repeat order activity, revenue volume, and identify peak repurchase days." + }, + "repeatCustomersTimeline": { + "title": "Repeat Customers Timeline", + "subtitle": "Analyze customer repurchase intervals, order journeys, and predict next purchase dates to prevent churn." } } diff --git a/apps/admin/src/locales/ja/translation.json b/apps/admin/src/locales/ja/translation.json index 94e16b1..2018b5d 100644 --- a/apps/admin/src/locales/ja/translation.json +++ b/apps/admin/src/locales/ja/translation.json @@ -58,6 +58,9 @@ "businessAnalytics": "ビジネス分析", "omnichannelSalesMap": "オムニチャネル売上マップ", "productSalesMap": "商品別売上マップ", + "repeatCustomers": "リピート顧客分析 (一覧)", + "repeatCustomersCalendar": "リピート顧客分析 (カレンダー)", + "repeatCustomersTimeline": "リピート顧客分析 (タイムライン)", "tools": "ツール", "marketplaceProducts": "マーケットプレイス商品", "tiktok": "TikTokチャネル", @@ -1381,6 +1384,33 @@ "CONTACT_INFO": "住所、電話番号、SNSなどを美しくまとめた問い合わせ先情報ボックス", "CONTACT_FORM": "一般的な問い合わせを受け付けるメールフォーム" } + }, + "repeatCustomers": { + "title": "リピート顧客分析 (Repeat Customers)", + "subtitle": "Shopee、Lazada、TikTokからのリピート購入行動、リピート率 (Repurchase Rate)、LTVを分析", + "exportCsv": "CSV出力", + "allChannels": "全チャネル (All Channels)", + "repeatOnly": "リピート顧客のみ (≥ 2回)", + "allBuyers": "全顧客 (≥ 1回)", + "kpiTotalCustomers": "総顧客数", + "kpiRepeatCustomers": "リピート顧客数", + "kpiRepurchaseRate": "リピート購入率", + "kpiRepeatRevenue": "リピート売上高", + "kpiAvgInterval": "平均リピートサイクル", + "frequencyDistribution": "購入頻度分布 (Purchase Frequency)", + "topRepeatProducts": "人気リピート購入商品", + "tableTitle": "リピート顧客リストと購入履歴", + "tabsTable": "テーブル表示", + "tabsCalendar": "カレンダー表示", + "tabsTimeline": "タイムライン表示" + }, + "repeatCustomersCalendar": { + "title": "リピート顧客カレンダー (Repeat Customers Calendar)", + "subtitle": "日々のリピート注文と売上推移を確認し、リピート購入のピーク日 (Peak Days) を分析" + }, + "repeatCustomersTimeline": { + "title": "リピート顧客タイムライン (Repeat Customers Timeline)", + "subtitle": "顧客ごとの購入サイクル (Purchase Cycles) を可視化し、離脱防止に向けた次回購入時期を予測" } } diff --git a/apps/admin/src/locales/th/translation.json b/apps/admin/src/locales/th/translation.json index adc68ba..54cda12 100644 --- a/apps/admin/src/locales/th/translation.json +++ b/apps/admin/src/locales/th/translation.json @@ -60,6 +60,9 @@ "productSalesMap": "แผนที่ยอดขายรายสินค้า", "malaysiaSalesMap": "แผนที่มาเลเซีย (Malaysia Map)", "vietnamSalesMap": "แผนที่เวียดนาม (Vietnam Map)", + "repeatCustomers": "ลูกค้าซื้อซ้ำ (ตาราง)", + "repeatCustomersCalendar": "ลูกค้าซื้อซ้ำ (ปฏิทิน)", + "repeatCustomersTimeline": "ลูกค้าซื้อซ้ำ (ไทม์ไลน์)", "tools": "เครื่องมือช่วยเหลือ", "marketplaceProducts": "สินค้า Marketplace", "tiktok": "ช่องทาง TikTok", @@ -1383,6 +1386,33 @@ "CONTACT_INFO": "กล่องข้อมูลติดต่อ แสดงที่อยู่ เบอร์โทร อีเมล และโซเชียลมีเดียอย่างสวยงาม", "CONTACT_FORM": "ฟอร์มส่งข้อความติดต่อสอบถามทั่วไปทางอีเมล" } + }, + "repeatCustomers": { + "title": "ลูกค้าซื้อซ้ำ (Repeat Customers)", + "subtitle": "วิเคราะห์พฤติกรรมการซื้อซ้ำของลูกค้าจาก Shopee, Lazada และ TikTok พร้อมตัวชี้วัด Repurchase Rate & LTV", + "exportCsv": "Export CSV", + "allChannels": "ทุกช่องทาง (All Channels)", + "repeatOnly": "เฉพาะลูกค้าซื้อซ้ำ (≥ 2 ครั้ง)", + "allBuyers": "ลูกค้าทั้งหมด (≥ 1 ครั้ง)", + "kpiTotalCustomers": "ลูกค้าทั้งหมด", + "kpiRepeatCustomers": "ลูกค้าซื้อซ้ำ", + "kpiRepurchaseRate": "อัตราการซื้อซ้ำ", + "kpiRepeatRevenue": "ยอดขายจากลูกค้าซื้อซ้ำ", + "kpiAvgInterval": "รอบเวลาซื้อซ้ำเฉลี่ย", + "frequencyDistribution": "การแจกแจงความถี่ในการสั่งซื้อ (Purchase Frequency)", + "topRepeatProducts": "สินค้ายอดฮิตที่ลูกค้าซื้อซ้ำมากที่สุด", + "tableTitle": "รายชื่อลูกค้าและประวัติการซื้อซ้ำ", + "tabsTable": "ตารางภาพรวม (Table)", + "tabsCalendar": "ปฏิทินการซื้อซ้ำ (Calendar)", + "tabsTimeline": "ไทม์ไลน์รอบซื้อ (Timeline)" + }, + "repeatCustomersCalendar": { + "title": "ปฏิทินการซื้อซ้ำ (Repeat Customers Calendar)", + "subtitle": "ตรวจสอบจังหวะและยอดคำสั่งซื้อซ้ำรายวัน และวิเคราะห์วันที่มีการซื้อซ้ำสูงสุด (Peak Days)" + }, + "repeatCustomersTimeline": { + "title": "ไทม์ไลน์รอบการซื้อซ้ำ (Repeat Customers Timeline)", + "subtitle": "วิเคราะห์วงรอบการกลับมาซื้อ (Purchase Cycles) และคาดการณ์กำหนดซื้อซ้ำรอบถัดไปเพื่อป้องกันการ Churn" } } diff --git a/apps/admin/src/locales/zh/translation.json b/apps/admin/src/locales/zh/translation.json index 73c97d5..db9a450 100644 --- a/apps/admin/src/locales/zh/translation.json +++ b/apps/admin/src/locales/zh/translation.json @@ -58,6 +58,9 @@ "businessAnalytics": "商业数据分析", "omnichannelSalesMap": "全渠道销售地图", "productSalesMap": "商品销售地图", + "repeatCustomers": "复购客户分析 (表格)", + "repeatCustomersCalendar": "复购客户分析 (日历)", + "repeatCustomersTimeline": "复购客户分析 (时间轴)", "tools": "辅助工具", "marketplaceProducts": "电商平台商品", "tiktok": "TikTok 渠道", @@ -1382,6 +1385,33 @@ "CONTACT_INFO": "精美联系方式展示框,汇集地址、电话、邮箱及社交媒体", "CONTACT_FORM": "通用客户咨询与支持邮件表单" } + }, + "repeatCustomers": { + "title": "复购客户分析 (Repeat Customers)", + "subtitle": "分析 Shopee、Lazada 和 TikTok 客户的复购行为、留存率 (Repurchase Rate) 以及客户终身价值 (LTV)", + "exportCsv": "导出 CSV", + "allChannels": "所有渠道 (All Channels)", + "repeatOnly": "仅复购客户 (≥ 2 次)", + "allBuyers": "全部客户 (≥ 1 次)", + "kpiTotalCustomers": "客户总数", + "kpiRepeatCustomers": "复购客户数", + "kpiRepurchaseRate": "复购率", + "kpiRepeatRevenue": "复购贡献销售额", + "kpiAvgInterval": "平均复购周期", + "frequencyDistribution": "购买频次分布 (Purchase Frequency)", + "topRepeatProducts": "最受复购欢迎商品", + "tableTitle": "复购客户名单与历史订单", + "tabsTable": "表格视图", + "tabsCalendar": "日历视图", + "tabsTimeline": "时间轴视图" + }, + "repeatCustomersCalendar": { + "title": "复购客户日历 (Repeat Customers Calendar)", + "subtitle": "查看每日复购订单与销售额,分析复购高峰日 (Peak Days)" + }, + "repeatCustomersTimeline": { + "title": "复购客户时间轴 (Repeat Customers Timeline)", + "subtitle": "分析客户复购周期 (Purchase Cycles) 并预测下次购买时间,防止客户流失" } } diff --git a/apps/admin/src/pages/analytics/RepeatCustomersCalendarPage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersCalendarPage.tsx new file mode 100644 index 0000000..3059d3f --- /dev/null +++ b/apps/admin/src/pages/analytics/RepeatCustomersCalendarPage.tsx @@ -0,0 +1,518 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Calendar as CalendarIcon, + ChevronLeft, + ChevronRight, + RefreshCw, + ShoppingBag, + DollarSign, + Users, + Flame, + X, + Clock, + ExternalLink, +} 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'; +import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal'; + +type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada'; +type StatusFilter = 'completed' | 'all' | 'cancelled'; + +export default function RepeatCustomersCalendarPage() { + const { t } = useTranslation(); + + // Current month state + const [currentDate, setCurrentDate] = useState(() => { + const d = new Date(); + return new Date(d.getFullYear(), d.getMonth(), 1); + }); + + const monthParam = useMemo(() => { + const y = currentDate.getFullYear(); + const m = String(currentDate.getMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; + }, [currentDate]); + + const [channel, setChannel] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [loading, setLoading] = useState(true); + const [calendarData, setCalendarData] = useState(null); + + // Selected Day for Drawer + const [selectedDayData, setSelectedDayData] = useState(null); + + // Selected Customer for Detail Modal + const [selectedCustomerForModal, setSelectedCustomerForModal] = useState(null); + + const fetchData = useCallback(() => { + setLoading(true); + const params = new URLSearchParams(); + params.set('month', monthParam); + if (channel !== 'all') params.set('channel', channel); + if (statusFilter) params.set('statusFilter', statusFilter); + + fetch(`${API_BASE}/business-analytics/repeat-customers/calendar?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((res) => res.json()) + .then((data) => { + setCalendarData(data); + }) + .catch((err) => console.error('Failed to load calendar repeat data:', err)) + .finally(() => setLoading(false)); + }, [monthParam, channel, statusFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Month navigation + const prevMonth = () => { + setCurrentDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)); + setSelectedDayData(null); + }; + + const nextMonth = () => { + setCurrentDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)); + setSelectedDayData(null); + }; + + const goToToday = () => { + const now = new Date(); + setCurrentDate(new Date(now.getFullYear(), now.getMonth(), 1)); + setSelectedDayData(null); + }; + + // Calendar Math + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const firstDayIndex = (new Date(year, month, 1).getDay() + 6) % 7; // Monday = 0 + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const daysMap = useMemo(() => { + const map = new Map(); + if (calendarData?.days) { + for (const d of calendarData.days) { + map.set(d.day, d); + } + } + return map; + }, [calendarData]); + + const maxDailyRevenue = useMemo(() => { + if (!calendarData?.days || calendarData.days.length === 0) return 1; + return Math.max(...calendarData.days.map((d: any) => d.revenue || 0), 1); + }, [calendarData]); + + const getHeatmapClass = (rev: number) => { + if (!rev) return 'bg-slate-950/40 border-white/5'; + const ratio = rev / maxDailyRevenue; + if (ratio > 0.75) return 'bg-indigo-600/30 border-indigo-500/50 shadow-lg shadow-indigo-600/10'; + if (ratio > 0.4) return 'bg-indigo-700/20 border-indigo-500/30'; + if (ratio > 0.15) return 'bg-indigo-800/15 border-indigo-500/20'; + return 'bg-slate-900 border-white/10'; + }; + + const monthNamesTh = [ + 'มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน', + 'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม', + ]; + + const weekdayNames = ['จันทร์', 'อังคาร', 'พุธ', 'พฤหัสบดี', 'ศุกร์', 'เสาร์', 'อาทิตย์']; + + const summary = calendarData?.summary || { + totalRepeatOrders: 0, + totalRepeatRevenue: 0, + totalUniqueRepeatCustomers: 0, + peakDay: { date: '', revenue: 0, orders: 0 }, + }; + + return ( +
+ {/* ── Top Header Bar ────────────────────────────────────────── */} +
+
+
+
+ +
+
+

+ {t('repeatCustomersCalendar.title', 'ปฏิทินการซื้อซ้ำ (Repeat Customers Calendar)')} +

+

+ {t( + 'repeatCustomersCalendar.subtitle', + 'ตรวจสอบจังหวะและยอดคำสั่งซื้อซ้ำรายวัน และวิเคราะห์วันที่มีการซื้อซ้ำสูงสุด (Peak Days)' + )} +

+
+
+
+ +
+ +
+
+ + {/* ── Nav Tabs Bar ───────────────────────────────────────────── */} + + + {/* ── Month Controls & Filter Bar ─────────────────────────────── */} +
+ {/* Month Navigator */} +
+ + +
+

+ {monthNamesTh[month]} {year + 543} +

+ +
+ + +
+ + {/* Channel & Status Filters */} +
+ {/* Channel selection */} +
+ + + + +
+ + {/* Status Filter */} + +
+
+ + {/* ── Monthly KPI Summary ────────────────────────────────────── */} +
+
+
+ ยอดขายซื้อซ้ำประจำเดือน + +
+
+ {fmtBaht(summary.totalRepeatRevenue)} +
+
+ +
+
+ ออเดอร์ซื้อซ้ำในเดือนนี้ + +
+
+ {fmtNum(summary.totalRepeatOrders)} ออเดอร์ +
+
+ +
+
+ ลูกค้าที่กลับมาซื้อซ้ำ + +
+
+ {fmtNum(summary.totalUniqueRepeatCustomers)} คน +
+
+ +
+
+ วันซื้อซ้ำสูงสุด (Peak Day) + +
+
+ {summary.peakDay?.date ? ( + <> + วันที่ {summary.peakDay.date.slice(8, 10)} + + {fmtBaht(summary.peakDay.revenue)} ({summary.peakDay.orders} ออเดอร์) + + + ) : ( + - + )} +
+
+
+ + {/* ── Calendar Grid ─────────────────────────────────────────── */} +
+ {/* Weekday headers */} +
+ {weekdayNames.map((w, idx) => ( +
= 5 ? 'text-amber-400/80' : ''}> + {w} +
+ ))} +
+ + {/* Days grid */} +
+ {/* Empty cells before month start */} + {Array.from({ length: firstDayIndex }).map((_, idx) => ( +
+ ))} + + {/* Days of month */} + {Array.from({ length: daysInMonth }).map((_, idx) => { + const dayNum = idx + 1; + const dayData = daysMap.get(dayNum); + const hasData = dayData && dayData.ordersCount > 0; + const isSelected = selectedDayData?.day === dayNum; + + return ( +
{ + if (hasData) { + setSelectedDayData(dayData); + } + }} + className={`min-h-[95px] sm:min-h-[115px] p-2 sm:p-2.5 rounded-xl border flex flex-col justify-between transition-all ${ + hasData + ? `cursor-pointer hover:border-indigo-400 hover:scale-[1.02] ${getHeatmapClass(dayData.revenue)}` + : 'bg-slate-950/30 border-white/5 text-slate-600' + } ${isSelected ? 'ring-2 ring-indigo-500 border-indigo-400' : ''}`} + > + {/* Top: Day number & order badge */} +
+ + {dayNum} + + + {hasData && ( + + {dayData.ordersCount} + + )} +
+ + {/* Bottom: Revenue & channel indicators */} + {hasData ? ( +
+
+ {fmtBaht(dayData.revenue)} +
+ +
+ {dayData.channelBreakdown.tiktok.orders > 0 && ( + + )} + {dayData.channelBreakdown.shopee.orders > 0 && ( + + )} + {dayData.channelBreakdown.lazada.orders > 0 && ( + + )} +
+
+ ) : ( +
ไม่มีคำสั่งซื้อ
+ )} +
+ ); + })} +
+
+ + {/* ── Daily Orders Side Drawer / Modal ───────────────────────── */} + {selectedDayData && ( +
+
+ {/* Drawer Header */} +
+
+

+ คำสั่งซื้อซ้ำ — วันที่ {selectedDayData.day} {monthNamesTh[month]} +

+
+ + ยอดรวม {fmtBaht(selectedDayData.revenue)} + + • + {selectedDayData.ordersCount} คำสั่งซื้อ + • + {selectedDayData.uniqueCustomersCount} ลูกค้า +
+
+ +
+ + {/* Orders List */} +
+ {selectedDayData.orders.map((order: any, idx: number) => { + const getBadge = (ch: string) => { + if (ch === 'tiktok') return TikTok; + if (ch === 'shopee') return Shopee; + return Lazada; + }; + + return ( +
+
+
+ {getBadge(order.channel)} + + {order.customerName} + +
+ + {fmtBaht(order.amount)} + +
+ +
+ #{order.orderId} + {order.time && ( + + + {order.time} น. + + )} +
+ + {order.topProducts?.length > 0 && ( +
+ {order.topProducts.map((p: string, pIdx: number) => ( + + {p} + + ))} +
+ )} + +
+ +
+
+ ); + })} +
+ + {/* Drawer Footer */} +
+ +
+
+
+ )} + + {/* ── Customer Detail Orders Modal ────────────────────────────── */} + {selectedCustomerForModal && ( + setSelectedCustomerForModal(null)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx new file mode 100644 index 0000000..14e0d4e --- /dev/null +++ b/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx @@ -0,0 +1,923 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Users, + UserCheck, + Repeat, + DollarSign, + Search, + Download, + RefreshCw, + Clock, + Layers, + Award, + ChevronLeft, + ChevronRight, + Filter, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { API_BASE } from '@homaroma/types'; +import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; +import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal'; +import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs'; + +type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada'; +type StatusFilter = 'completed' | 'all' | 'cancelled'; +type TimePeriod = 'all' | '30days' | '90days' | 'this_year' | 'custom'; +type SortBy = 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween'; + +export default function RepeatCustomersPage() { + const { t } = useTranslation(); + + // Filters state + const [channel, setChannel] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [minOrders, setMinOrders] = useState(2); // default: repeat customers (>= 2) + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [sortBy, setSortBy] = useState('orders'); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(25); + + // Data state + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + // Debounce search + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 400); + return () => clearTimeout(handler); + }, [search]); + + // Handle Preset Period Change + const handlePeriodChange = (newPeriod: TimePeriod) => { + setPeriod(newPeriod); + setPage(1); + const now = new Date(); + if (newPeriod === 'all') { + setDateFrom(''); + setDateTo(''); + } else if (newPeriod === '30days') { + const past = new Date(); + past.setDate(now.getDate() - 30); + setDateFrom(past.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } else if (newPeriod === '90days') { + const past = new Date(); + past.setDate(now.getDate() - 90); + setDateFrom(past.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } else if (newPeriod === 'this_year') { + const startOfYear = new Date(now.getFullYear(), 0, 1); + setDateFrom(startOfYear.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } + }; + + // Fetch Data + const fetchData = useCallback(() => { + setLoading(true); + const params = new URLSearchParams(); + if (channel !== 'all') params.set('channel', channel); + if (statusFilter) params.set('statusFilter', statusFilter); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + params.set('minOrders', minOrders.toString()); + if (debouncedSearch) params.set('search', debouncedSearch); + params.set('sortBy', sortBy); + params.set('page', page.toString()); + params.set('limit', limit.toString()); + + fetch(`${API_BASE}/business-analytics/repeat-customers?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((res) => res.json()) + .then((resData) => { + setData(resData); + }) + .catch((err) => console.error('Failed to load repeat customers data:', err)) + .finally(() => setLoading(false)); + }, [channel, statusFilter, dateFrom, dateTo, minOrders, debouncedSearch, sortBy, page, limit]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Export to CSV + const handleExportCsv = () => { + if (!data || !data.customers || data.customers.length === 0) return; + + const headers = [ + 'Customer Identifier', + 'Display Name', + 'Primary Channel', + 'Channels', + 'Total Orders', + 'Total Spent (THB)', + 'AOV (THB)', + 'First Order Date', + 'Last Order Date', + 'Avg Days Between Orders', + 'Location', + 'Top Purchased Products', + ]; + + const rows = data.customers.map((c: any) => [ + `"${c.identifier || ''}"`, + `"${c.displayName || ''}"`, + `"${c.primaryChannel || ''}"`, + `"${(c.channels || []).join(', ')}"`, + c.orderCount, + c.totalSpent, + c.avgOrderValue, + `"${c.firstOrderDate || ''}"`, + `"${c.lastOrderDate || ''}"`, + c.daysBetweenOrders || 0, + `"${c.location || ''}"`, + `"${(c.topProducts || []).join(', ')}"`, + ]); + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((r: any) => r.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute( + 'download', + `homaroma_repeat_customers_${channel}_${new Date().toISOString().slice(0, 10)}.csv` + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const summary = data?.summary || { + totalCustomers: 0, + repeatCustomers: 0, + singleOrderCustomers: 0, + repurchaseRate: 0, + totalRevenue: 0, + repeatRevenue: 0, + repeatRevenueShare: 0, + avgRepurchaseIntervalDays: 0, + }; + + const getChannelBadge = (ch: string) => { + switch (ch) { + case 'tiktok': + return ( + + TikTok + + ); + case 'shopee': + return ( + + Shopee + + ); + case 'lazada': + return ( + + Lazada + + ); + default: + return ( + + {ch} + + ); + } + }; + + return ( +
+ {/* ── Top Header Bar ────────────────────────────────────────── */} +
+
+
+
+ +
+
+

+ {t('repeatCustomers.title', 'ลูกค้าซื้อซ้ำ (Repeat Customers)')} +

+

+ {t( + 'repeatCustomers.subtitle', + 'วิเคราะห์พฤติกรรมการซื้อซ้ำของลูกค้าจาก Shopee, Lazada และ TikTok พร้อมตัวชี้วัด Repurchase Rate & LTV' + )} +

+
+
+
+ +
+ + + +
+
+ + {/* ── Nav Tabs Bar ───────────────────────────────────────────── */} + + + {/* ── Filters & Controls Bar ─────────────────────────────────── */} +
+ {/* Channel Selection Tabs */} +
+
+ + + + +
+ + {/* Min Orders Filter Toggle */} +
+ + +
+
+ + {/* Secondary Filter Row */} +
+ {/* Period Presets */} +
+ {( + [ + { key: 'all', label: 'ทั้งหมด' }, + { key: '30days', label: '30 วันล่าสุด' }, + { key: '90days', label: '90 วันล่าสุด' }, + { key: 'this_year', label: 'ปีนี้' }, + { key: 'custom', label: 'กำหนดเอง' }, + ] as { key: TimePeriod; label: string }[] + ).map((p) => ( + + ))} +
+ + {/* Custom Date Picker inputs */} + {period === 'custom' && ( +
+ { + setDateFrom(e.target.value); + setPage(1); + }} + className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-500" + /> + ถึง + { + setDateTo(e.target.value); + setPage(1); + }} + className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-500" + /> +
+ )} + + {/* Status Filter & Search Box */} +
+ + +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อลูกค้า, Username, จังหวัด..." + 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-indigo-500 transition-all" + /> +
+
+
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────── */} +
+ {/* Card 1: Total Unique Customers */} +
+
+ + {t('repeatCustomers.kpiTotalCustomers', 'ลูกค้าทั้งหมด')} + +
+ +
+
+
+ + {fmtNum(summary.totalCustomers)} + + คน +
+

+ ลูกค้าไม่ซ้ำ (Unique Customers) +

+
+ + {/* Card 2: Repeat Customers Count */} +
+
+ + {t('repeatCustomers.kpiRepeatCustomers', 'ลูกค้าซื้อซ้ำ')} + +
+ +
+
+
+ + {fmtNum(summary.repeatCustomers)} + + คน +
+

+ สั่งซื้อตั้งแต่ 2 ครั้งขึ้นไป +

+
+ + {/* Card 3: Repurchase Rate % */} +
+
+ + {t('repeatCustomers.kpiRepurchaseRate', 'อัตราการซื้อซ้ำ')} + +
+ +
+
+
+ + {summary.repurchaseRate}% + +
+

+ สัดส่วนลูกค้าที่กลับมาซื้อซ้ำ +

+
+ + {/* Card 4: Repeat Revenue */} +
+
+ + {t('repeatCustomers.kpiRepeatRevenue', 'ยอดขายจากลูกค้าซื้อซ้ำ')} + +
+ +
+
+
+ + {fmtBaht(summary.repeatRevenue)} + +
+

+ คิดเป็น {summary.repeatRevenueShare}% ของยอดขายรวม +

+
+ + {/* Card 5: Average Days to Repurchase */} +
+
+ + {t('repeatCustomers.kpiAvgInterval', 'รอบเวลาซื้อซ้ำเฉลี่ย')} + +
+ +
+
+
+ + {summary.avgRepurchaseIntervalDays > 0 ? `${summary.avgRepurchaseIntervalDays}` : '-'} + + วัน +
+

+ ระยะห่างเฉลี่ยระหว่างการซื้อซ้ำ +

+
+
+ + {/* ── Cohorts & Top Products Grid (2 Columns) ────────────────── */} +
+ {/* Col 1: Frequency Cohorts (7 cols) */} +
+
+
+
+ +

+ {t('repeatCustomers.frequencyDistribution', 'การแจกแจงความถี่ในการสั่งซื้อ (Purchase Frequency)')} +

+
+ จำแนกตามจำนวนออเดอร์ +
+ +
+ {data?.cohorts?.map((cohort: any, idx: number) => { + const colors = [ + 'bg-slate-700', + 'bg-blue-500', + 'bg-purple-500', + 'bg-amber-400', + ]; + const textColors = [ + 'text-slate-300', + 'text-blue-400', + 'text-purple-400', + 'text-amber-400', + ]; + + return ( +
+
+ + {cohort.label} + +
+ + {fmtNum(cohort.count)} คน ({cohort.percentage}%) + + + {fmtBaht(cohort.revenue)} + +
+
+ {/* Progress bar */} +
+
+
+
+ ); + })} +
+
+ + {/* Channel stats footer */} + {data?.channelBreakdown && ( +
+
+ TikTok Shop + + {data.channelBreakdown.tiktok?.repurchaseRate}% + + + {fmtNum(data.channelBreakdown.tiktok?.repeatCust || 0)} คนซื้อซ้ำ + +
+
+ Shopee + + {data.channelBreakdown.shopee?.repurchaseRate}% + + + {fmtNum(data.channelBreakdown.shopee?.repeatCust || 0)} คนซื้อซ้ำ + +
+
+ Lazada + + {data.channelBreakdown.lazada?.repurchaseRate}% + + + {fmtNum(data.channelBreakdown.lazada?.repeatCust || 0)} คนซื้อซ้ำ + +
+
+ )} +
+ + {/* Col 2: Top Repurchased Products (5 cols) */} +
+
+
+
+ +

+ {t('repeatCustomers.topRepeatProducts', 'สินค้ายอดฮิตที่ลูกค้าซื้อซ้ำมากที่สุด')} +

+
+ Top 10 SKUs +
+ +
+ {!data?.topRepeatProducts || data.topRepeatProducts.length === 0 ? ( +
+ ไม่มีข้อมูลสินค้าที่ถูกซื้อซ้ำในช่วงเวลานี้ +
+ ) : ( + data.topRepeatProducts.map((p: any, idx: number) => ( +
+
+ + {idx + 1} + + + {p.productName} + +
+ +
+ + {fmtNum(p.repeatBuyerCount)} คน + + + ({fmtNum(p.orderAppearances)} ออเดอร์) + +
+
+ )) + )} +
+
+ +

+ แนะนำให้นำสินค้าเหล่านี้ไปทำแคมเปญ Re-order และโปรโมชั่นซื้อซ้ำ (Bundle/Subscription) +

+
+
+ + {/* ── Repeat Customers Table ─────────────────────────────────── */} +
+ {/* Table Top Controls */} +
+
+ +
+

+ {t('repeatCustomers.tableTitle', 'รายชื่อลูกค้าและประวัติการซื้อซ้ำ')} +

+

+ พบทั้งหมด {fmtNum(data?.pagination?.total || 0)} รายการ +

+
+
+ + {/* Sort By selector */} +
+ + + จัดเรียงตาม: + + +
+
+ + {/* Table View */} +
+ + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : !data?.customers || data.customers.length === 0 ? ( + + + + ) : ( + data.customers.map((cust: any, idx: number) => { + const rowNumber = (page - 1) * limit + idx + 1; + return ( + + + + + + + + + + + + + + + + + + + + + + ); + }) + )} + +
#ลูกค้า (Identifier / Name)ช่องทางจำนวนออเดอร์ยอดซื้อสะสม (LTV)ยอดเฉลี่ย / ออเดอร์ซื้อล่าสุดรอบซื้อซ้ำเฉลี่ยสินค้าที่ซื้อบ่อยการจัดการ
+
+ กำลังประมวลผลข้อมูลลูกค้าซื้อซ้ำ... +
+ + ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข +
+ {rowNumber} + +
+ + {cust.displayName} + + {cust.location && ( + + {cust.location} + + )} +
+
+
+ {getChannelBadge(cust.primaryChannel)} + {cust.shops?.length > 1 && ( + + +{cust.shops.length - 1} ร้าน + + )} +
+
+ = 6 + ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30' + : cust.orderCount >= 3 + ? 'bg-purple-500/20 text-purple-300 border border-purple-500/30' + : 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' + }`} + > + {cust.orderCount} ครั้ง + + + {fmtBaht(cust.totalSpent)} + + {fmtBaht(cust.avgOrderValue)} + +
+ + {cust.lastOrderDate?.slice(0, 10) || '-'} + + + แรกเริ่ม: {cust.firstOrderDate?.slice(0, 10) || '-'} + +
+
+ {cust.daysBetweenOrders > 0 ? ( + + {cust.daysBetweenOrders} วัน + + ) : ( + - + )} + +
+ {cust.topProducts?.map((prod: string, pIdx: number) => ( + + {prod} + + ))} +
+
+ +
+
+ + {/* Table Pagination Bar */} + {data?.pagination && data.pagination.totalPages > 1 && ( +
+
+ + กำลังแสดงหน้า {page} จาก{' '} + {data.pagination.totalPages} หน้า + +
+ แสดง: + +
+
+ +
+ + + + {page} / {data.pagination.totalPages} + + + +
+
+ )} +
+ + {/* ── Order History Modal ─────────────────────────────────────── */} + {selectedCustomer && ( + setSelectedCustomer(null)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx new file mode 100644 index 0000000..47af434 --- /dev/null +++ b/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx @@ -0,0 +1,566 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Clock, + RefreshCw, + Search, + AlertTriangle, + CheckCircle2, + AlertCircle, + Users, + ChevronLeft, + ChevronRight, + MapPin, + ExternalLink, + 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'; +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 [channel, setChannel] = useState('all'); + const [churnStatus, setChurnStatus] = useState('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 [data, setData] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(null); + + // Debounce search + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 400); + return () => clearTimeout(handler); + }, [search]); + + const fetchData = useCallback(() => { + setLoading(true); + const params = new URLSearchParams(); + if (channel !== 'all') params.set('channel', channel); + if (churnStatus !== 'all') params.set('churnStatus', churnStatus); + if (debouncedSearch) params.set('search', debouncedSearch); + params.set('page', page.toString()); + params.set('limit', limit.toString()); + + fetch(`${API_BASE}/business-analytics/repeat-customers/timeline?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((res) => res.json()) + .then((resData) => { + setData(resData); + }) + .catch((err) => console.error('Failed to load repeat timeline data:', err)) + .finally(() => setLoading(false)); + }, [channel, churnStatus, debouncedSearch, page, limit]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const summary = data?.summary || { + totalRepeatCustomers: 0, + healthyCount: 0, + dueSoonCount: 0, + churnRiskCount: 0, + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'due_soon': + return ( + + + ถึงรอบซื้อซ้ำ (Due Soon) + + ); + case 'healthy': + return ( + + + ซื้อตามรอบปกติ (Active) + + ); + case 'churn_risk': + return ( + + + เกินรอบซื้อ (Churn Risk) + + ); + default: + return null; + } + }; + + const getChannelBadge = (ch: string) => { + if (ch === 'tiktok') return TikTok Shop; + if (ch === 'shopee') return Shopee; + return Lazada; + }; + + return ( +
+ {/* ── Top Header Bar ────────────────────────────────────────── */} +
+
+
+
+ +
+
+

+ {t('repeatCustomersTimeline.title', 'ไทม์ไลน์รอบการซื้อซ้ำ (Repeat Customers Timeline)')} +

+

+ {t( + 'repeatCustomersTimeline.subtitle', + 'วิเคราะห์วงรอบการกลับมาซื้อ (Purchase Cycles) และคาดการณ์กำหนดซื้อซ้ำรอบถัดไปเพื่อป้องกันการ Churn' + )} +

+
+
+
+ +
+ +
+
+ + {/* ── Nav Tabs Bar ───────────────────────────────────────────── */} + + + {/* ── Churn Risk & Retention Summary KPI Cards ───────────────── */} +
+
+
+ ลูกค้าซื้อซ้ำทั้งหมด + +
+
+ {fmtNum(summary.totalRepeatCustomers)} คน +
+

ลูกค้าที่มี ≥ 2 ออเดอร์

+
+ + {/* Due Soon Card */} +
{ + setChurnStatus('due_soon'); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + churnStatus === 'due_soon' ? 'border-amber-400 ring-1 ring-amber-400' : 'border-amber-500/20 hover:border-amber-500/40' + }`} + > +
+ ถึงรอบซื้อซ้ำ (Due Soon) + +
+
+ {fmtNum(summary.dueSoonCount)} คน +
+

โอกาสทองในการส่งโปรโมชัน

+
+ + {/* Healthy Card */} +
{ + setChurnStatus('healthy'); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + churnStatus === 'healthy' ? 'border-emerald-400 ring-1 ring-emerald-400' : 'border-emerald-500/20 hover:border-emerald-500/40' + }`} + > +
+ ซื้อตามรอบปกติ (Active) + +
+
+ {fmtNum(summary.healthyCount)} คน +
+

ระยะเวลายังไม่เกินรอบเฉลี่ย

+
+ + {/* Churn Risk Card */} +
{ + setChurnStatus('churn_risk'); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + churnStatus === 'churn_risk' ? 'border-rose-400 ring-1 ring-rose-400' : 'border-rose-500/20 hover:border-rose-500/40' + }`} + > +
+ เสี่ยงหลุด (Churn Risk) + +
+
+ {fmtNum(summary.churnRiskCount)} คน +
+

เกินรอบเฉลี่ยไปมาก (Win-back)

+
+
+ + {/* ── Filters & Search Bar ───────────────────────────────────── */} +
+ {/* Status Filter Tabs */} +
+ + + + +
+ + {/* Channel Selection */} +
+ + + + +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อลูกค้า, Username..." + 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-indigo-500 transition-all" + /> +
+
+ + {/* ── Customer Journey Swimlane Cards ────────────────────────── */} +
+ {loading ? ( +
+
+ กำลังคำนวณไทม์ไลน์และรอบการซื้อซ้ำ... +
+ ) : !data?.customers || data.customers.length === 0 ? ( +
+ + ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข +
+ ) : ( + data.customers.map((cust: any) => ( +
+ {/* Card Top: Customer Identity & Status */} +
+
+
+ {cust.displayName.slice(0, 2)} +
+
+
+ + {cust.displayName} + + {getChannelBadge(cust.primaryChannel)} + {getStatusBadge(cust.churnStatus)} +
+
+ {cust.location && ( + + + {cust.location} + + )} + • + + ยอดสะสม {fmtBaht(cust.totalSpent)} + + • + + {cust.orderCount} คำสั่งซื้อ + +
+
+
+ + {/* Right side: Cycle metrics */} +
+
+ รอบซื้อเฉลี่ย + + {cust.avgIntervalDays} วัน + +
+
+
+ ซื้อล่าสุด + + {cust.daysSinceLastOrder} วันที่แล้ว + +
+
+
+ คาดการณ์รอบถัดไป + + {cust.predictedNextOrderDate} + +
+
+
+ + {/* Visual Horizontal Timeline Bar (Swimlane) */} +
+
+ ไทม์ไลน์คำสั่งซื้อ (Order Timeline) + + {cust.firstOrderDate.slice(0, 10)} ───► {cust.lastOrderDate.slice(0, 10)} + +
+ + {/* Horizontal Bar with Nodes */} +
+
+ {/* Connecting line */} +
+ + {/* Order Nodes */} + {cust.orders.map((o: any, oIdx: number) => { + const isLast = oIdx === cust.orders.length - 1; + return ( +
+ {/* Node Circle */} +
+ {oIdx + 1} +
+ + {/* Node Label Below */} +
+ + {o.date.slice(5, 10)} + + + {fmtBaht(o.amount)} + +
+
+ ); + })} + + {/* Predicted Next Order Marker */} +
+
+ +
+
+ + รอบถัดไป + + + ~{cust.predictedNextOrderDate.slice(5, 10)} + +
+
+
+
+
+ + {/* Card Bottom Actions */} +
+ + {cust.orders?.[0]?.productNames?.[0] ? `สินค้าล่าสุด: ${cust.orders[cust.orders.length - 1]?.productNames?.[0] || ''}` : ''} + + + +
+
+ )) + )} +
+ + {/* ── Pagination Bar ─────────────────────────────────────────── */} + {data?.pagination && data.pagination.totalPages > 1 && ( +
+
+ + กำลังแสดงหน้า {page} จาก{' '} + {data.pagination.totalPages} หน้า (ทั้งหมด {fmtNum(data.pagination.total)} ลูกค้า) + +
+ แสดง: + +
+
+ +
+ + + + {page} / {data.pagination.totalPages} + + + +
+
+ )} + + {/* ── Order Detail Modal ─────────────────────────────────────── */} + {selectedCustomer && ( + setSelectedCustomer(null)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx index da12336..c3483ad 100644 --- a/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx +++ b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx @@ -23,7 +23,6 @@ import { Check, Building2, Layers, - Sparkle, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { API_BASE } from '@homaroma/types'; diff --git a/apps/backend/src/routes/businessAnalytics.ts b/apps/backend/src/routes/businessAnalytics.ts index e664468..64d9d05 100644 --- a/apps/backend/src/routes/businessAnalytics.ts +++ b/apps/backend/src/routes/businessAnalytics.ts @@ -1389,5 +1389,1396 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" districts, orders: matchedOrders, }; + }) + + // ── GET Repeat Customers Analysis (Shopee + Lazada + TikTok) ────────────── + .get("/repeat-customers", async ({ query }) => { + const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' + const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled' + const sf = buildStatusFilter(statusFilter); + + const tiktokShop = query.tiktokShop as string | undefined; + const shopeeShop = query.shopeeShop as string | undefined; + const lazadaShop = query.lazadaShop as string | undefined; + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + const minOrders = parseInt((query.minOrders as string) || "2", 10); + const search = ((query.search as string) || "").trim().toLowerCase(); + const sortBy = (query.sortBy as string) || "orders"; // 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween' + const sortOrder = (query.sortOrder as string) || "desc"; // 'desc' | 'asc' + const page = Math.max(1, parseInt((query.page as string) || "1", 10)); + const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "25", 10))); + + type OrderSummary = { + orderId: string; + channel: "tiktok" | "shopee" | "lazada"; + shop: string; + date: string; + timestamp: number; + amount: number; + status: string; + itemsCount: number; + productNames: string[]; + }; + + type CustomerProfile = { + id: string; + identifier: string; + displayName: string; + primaryChannel: "tiktok" | "shopee" | "lazada"; + channels: Set; + shops: Set; + orderCount: number; + totalSpent: number; + firstOrderDate: string; + lastOrderDate: string; + firstTimestamp: number; + lastTimestamp: number; + daysBetweenOrders: number; + location: string; + orders: OrderSummary[]; + productFrequency: Map; + }; + + const customerMap = new Map(); + + // 1. Fetch TikTok Orders + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (tiktokShop && tiktokShop !== "all") where.shop = tiktokShop; + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const tiktokOrders = await prisma.tiktokOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + status: true, + buyerUsername: true, + orderAmount: true, + createdTime: true, + province: true, + district: true, + items: { + select: { + productName: true, + quantity: true, + }, + }, + }, + }); + + for (const o of tiktokOrders) { + const rawUser = (o.buyerUsername || "").trim(); + const identifier = rawUser || `TT_${o.orderId}`; + const key = `tiktok:${identifier.toLowerCase()}`; + const dateStr = o.createdTime || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + const location = [o.district, o.province].filter(Boolean).join(", ") || o.province || ""; + + let prof = customerMap.get(key); + if (!prof) { + prof = { + id: key, + identifier, + displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, + primaryChannel: "tiktok", + channels: new Set(["tiktok"]), + shops: new Set([o.shop]), + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + daysBetweenOrders: 0, + location, + orders: [], + productFrequency: new Map(), + }; + customerMap.set(key, prof); + } + + prof.orderCount += 1; + prof.totalSpent += amount; + prof.shops.add(o.shop); + if (location && !prof.location) prof.location = location; + + if (ts > 0) { + if (!prof.firstTimestamp || ts < prof.firstTimestamp) { + prof.firstTimestamp = ts; + prof.firstOrderDate = dateStr; + } + if (!prof.lastTimestamp || ts > prof.lastTimestamp) { + prof.lastTimestamp = ts; + prof.lastOrderDate = dateStr; + } + } + + for (const pName of prodNames) { + prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); + } + + prof.orders.push({ + orderId: o.orderId, + channel: "tiktok", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + status: o.status, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + productNames: prodNames, + }); + } + } + + // 2. Fetch Shopee Orders + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (shopeeShop && shopeeShop !== "all") where.shop = shopeeShop; + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const shopeeOrders = await prisma.shopeeOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + status: true, + buyerUsername: true, + orderCreatedAt: true, + buyerPaidAmount: true, + totalAmount: true, + items: { + select: { + productName: true, + quantity: true, + rawData: true, + }, + }, + }, + }); + + for (const o of shopeeOrders) { + const rawUser = (o.buyerUsername || "").trim(); + const identifier = rawUser || `SP_${o.orderId}`; + const key = `shopee:${identifier.toLowerCase()}`; + const dateStr = o.orderCreatedAt || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + const { province, district } = extractShopeeAddress(o); + const location = [district, province].filter(Boolean).join(", ") || province || ""; + + let prof = customerMap.get(key); + if (!prof) { + prof = { + id: key, + identifier, + displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, + primaryChannel: "shopee", + channels: new Set(["shopee"]), + shops: new Set([o.shop]), + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + daysBetweenOrders: 0, + location, + orders: [], + productFrequency: new Map(), + }; + customerMap.set(key, prof); + } + + prof.orderCount += 1; + prof.totalSpent += amount; + prof.shops.add(o.shop); + if (location && !prof.location) prof.location = location; + + if (ts > 0) { + if (!prof.firstTimestamp || ts < prof.firstTimestamp) { + prof.firstTimestamp = ts; + prof.firstOrderDate = dateStr; + } + if (!prof.lastTimestamp || ts > prof.lastTimestamp) { + prof.lastTimestamp = ts; + prof.lastOrderDate = dateStr; + } + } + + for (const pName of prodNames) { + prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); + } + + prof.orders.push({ + orderId: o.orderId, + channel: "shopee", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + status: o.status, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + productNames: prodNames, + }); + } + } + + // 3. Fetch Lazada Orders + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (lazadaShop && lazadaShop !== "all") where.shop = lazadaShop; + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const lazadaOrders = await prisma.lazadaOrder.findMany({ + where, + select: { + orderNumber: true, + shop: true, + status: true, + customerName: true, + shippingCity: true, + shippingRegion: true, + shippingPostCode: true, + orderAmount: true, + createTime: true, + items: { + select: { + itemName: true, + }, + }, + }, + }); + + for (const o of lazadaOrders) { + const rawName = (o.customerName || "").trim(); + const postCode = (o.shippingPostCode || "").trim(); + const identifier = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; + const key = `lazada:${identifier}`; + const dateStr = o.createTime || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); + const { province, district } = extractLazadaAddress(o); + const location = [district, province].filter(Boolean).join(", ") || province || ""; + + let prof = customerMap.get(key); + if (!prof) { + prof = { + id: key, + identifier, + displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, + primaryChannel: "lazada", + channels: new Set(["lazada"]), + shops: new Set([o.shop]), + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + daysBetweenOrders: 0, + location, + orders: [], + productFrequency: new Map(), + }; + customerMap.set(key, prof); + } + + prof.orderCount += 1; + prof.totalSpent += amount; + prof.shops.add(o.shop); + if (location && !prof.location) prof.location = location; + + if (ts > 0) { + if (!prof.firstTimestamp || ts < prof.firstTimestamp) { + prof.firstTimestamp = ts; + prof.firstOrderDate = dateStr; + } + if (!prof.lastTimestamp || ts > prof.lastTimestamp) { + prof.lastTimestamp = ts; + prof.lastOrderDate = dateStr; + } + } + + for (const pName of prodNames) { + prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); + } + + prof.orders.push({ + orderId: o.orderNumber, + channel: "lazada", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + status: o.status || "Completed", + itemsCount: o.items.length, + productNames: prodNames, + }); + } + } + + // 4. Calculate Aggregate Metrics & Repeat Stats + const allCustomers = Array.from(customerMap.values()); + const totalCustomers = allCustomers.length; + let repeatCustomersCount = 0; + let singleOrderCustomersCount = 0; + let totalRevenue = 0; + let repeatRevenue = 0; + let totalIntervalDays = 0; + let customersWithIntervalCount = 0; + + const cohorts = { + order1: { count: 0, revenue: 0 }, + order2: { count: 0, revenue: 0 }, + order3to5: { count: 0, revenue: 0 }, + order6plus: { count: 0, revenue: 0 }, + }; + + const channelStats: Record = { + tiktok: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, + shopee: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, + lazada: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, + }; + + const repeatProductMap = new Map(); + + for (const c of allCustomers) { + totalRevenue += c.totalSpent; + const isRepeat = c.orderCount >= 2; + + // Calculate days between orders + if (c.orderCount > 1 && c.firstTimestamp > 0 && c.lastTimestamp > c.firstTimestamp) { + const diffDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24); + c.daysBetweenOrders = Number((diffDays / (c.orderCount - 1)).toFixed(1)); + totalIntervalDays += c.daysBetweenOrders; + customersWithIntervalCount += 1; + } + + if (isRepeat) { + repeatCustomersCount += 1; + repeatRevenue += c.totalSpent; + + // Track products for repeat buyers + for (const [prodName, freq] of c.productFrequency.entries()) { + const prev = repeatProductMap.get(prodName) || { repeatBuyerCount: 0, orderAppearances: 0 }; + repeatProductMap.set(prodName, { + repeatBuyerCount: prev.repeatBuyerCount + 1, + orderAppearances: prev.orderAppearances + freq, + }); + } + } else { + singleOrderCustomersCount += 1; + } + + // Cohorts + if (c.orderCount === 1) { + cohorts.order1.count += 1; + cohorts.order1.revenue += c.totalSpent; + } else if (c.orderCount === 2) { + cohorts.order2.count += 1; + cohorts.order2.revenue += c.totalSpent; + } else if (c.orderCount >= 3 && c.orderCount <= 5) { + cohorts.order3to5.count += 1; + cohorts.order3to5.revenue += c.totalSpent; + } else { + cohorts.order6plus.count += 1; + cohorts.order6plus.revenue += c.totalSpent; + } + + // Channel stats + const ch = c.primaryChannel; + if (channelStats[ch]) { + channelStats[ch].totalCust += 1; + channelStats[ch].totalRev += c.totalSpent; + if (isRepeat) { + channelStats[ch].repeatCust += 1; + channelStats[ch].repeatRev += c.totalSpent; + } + } + } + + // Top Repeat Products List + const topRepeatProducts = Array.from(repeatProductMap.entries()) + .map(([name, data]) => ({ + productName: name, + repeatBuyerCount: data.repeatBuyerCount, + orderAppearances: data.orderAppearances, + })) + .sort((a, b) => b.repeatBuyerCount - a.repeatBuyerCount) + .slice(0, 10); + + // 5. Filter & Sort Customers for Table + let filteredCustomers = allCustomers; + + // Minimum orders filter (default >= 2 for repeat buyers only) + if (minOrders > 1) { + filteredCustomers = filteredCustomers.filter((c) => c.orderCount >= minOrders); + } + + // Search filter + if (search) { + filteredCustomers = filteredCustomers.filter( + (c) => + c.displayName.toLowerCase().includes(search) || + c.identifier.toLowerCase().includes(search) || + c.location.toLowerCase().includes(search) + ); + } + + // Sorting + filteredCustomers.sort((a, b) => { + let comparison = 0; + if (sortBy === "totalSpent") { + comparison = b.totalSpent - a.totalSpent; + } else if (sortBy === "lastOrderDate") { + comparison = String(b.lastOrderDate || "").localeCompare(String(a.lastOrderDate || "")); + } else if (sortBy === "daysBetween") { + comparison = (a.daysBetweenOrders || 9999) - (b.daysBetweenOrders || 9999); + } else { + // default 'orders' + comparison = b.orderCount - a.orderCount || b.totalSpent - a.totalSpent; + } + return sortOrder === "asc" ? -comparison : comparison; + }); + + const totalFiltered = filteredCustomers.length; + const totalPages = Math.ceil(totalFiltered / limit); + const paginatedCustomers = filteredCustomers.slice((page - 1) * limit, page * limit); + + // Clean output for customers + const formattedCustomers = paginatedCustomers.map((c) => { + // Top 3 products + const sortedProds = Array.from(c.productFrequency.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([name]) => name); + + return { + id: c.id, + identifier: c.identifier, + displayName: c.displayName, + primaryChannel: c.primaryChannel, + channels: Array.from(c.channels), + shops: Array.from(c.shops), + orderCount: c.orderCount, + totalSpent: Number(c.totalSpent.toFixed(2)), + avgOrderValue: c.orderCount > 0 ? Number((c.totalSpent / c.orderCount).toFixed(2)) : 0, + firstOrderDate: c.firstOrderDate, + lastOrderDate: c.lastOrderDate, + daysBetweenOrders: c.daysBetweenOrders, + location: c.location, + topProducts: sortedProds, + }; + }); + + const repurchaseRate = totalCustomers > 0 ? Number(((repeatCustomersCount / totalCustomers) * 100).toFixed(2)) : 0; + const repeatRevenueShare = totalRevenue > 0 ? Number(((repeatRevenue / totalRevenue) * 100).toFixed(2)) : 0; + const avgInterval = customersWithIntervalCount > 0 ? Number((totalIntervalDays / customersWithIntervalCount).toFixed(1)) : 0; + + return { + summary: { + totalCustomers, + repeatCustomers: repeatCustomersCount, + singleOrderCustomers: singleOrderCustomersCount, + repurchaseRate, + totalRevenue: Number(totalRevenue.toFixed(2)), + repeatRevenue: Number(repeatRevenue.toFixed(2)), + repeatRevenueShare, + avgRepurchaseIntervalDays: avgInterval, + }, + cohorts: [ + { + label: "1 คำสั่งซื้อ", + count: cohorts.order1.count, + revenue: Number(cohorts.order1.revenue.toFixed(2)), + percentage: totalCustomers > 0 ? Number(((cohorts.order1.count / totalCustomers) * 100).toFixed(1)) : 0, + }, + { + label: "2 คำสั่งซื้อ", + count: cohorts.order2.count, + revenue: Number(cohorts.order2.revenue.toFixed(2)), + percentage: totalCustomers > 0 ? Number(((cohorts.order2.count / totalCustomers) * 100).toFixed(1)) : 0, + }, + { + label: "3 - 5 คำสั่งซื้อ", + count: cohorts.order3to5.count, + revenue: Number(cohorts.order3to5.revenue.toFixed(2)), + percentage: totalCustomers > 0 ? Number(((cohorts.order3to5.count / totalCustomers) * 100).toFixed(1)) : 0, + }, + { + label: "6+ คำสั่งซื้อ (VIP)", + count: cohorts.order6plus.count, + revenue: Number(cohorts.order6plus.revenue.toFixed(2)), + percentage: totalCustomers > 0 ? Number(((cohorts.order6plus.count / totalCustomers) * 100).toFixed(1)) : 0, + }, + ], + channelBreakdown: { + tiktok: { + ...channelStats.tiktok, + repurchaseRate: + channelStats.tiktok.totalCust > 0 + ? Number(((channelStats.tiktok.repeatCust / channelStats.tiktok.totalCust) * 100).toFixed(1)) + : 0, + }, + shopee: { + ...channelStats.shopee, + repurchaseRate: + channelStats.shopee.totalCust > 0 + ? Number(((channelStats.shopee.repeatCust / channelStats.shopee.totalCust) * 100).toFixed(1)) + : 0, + }, + lazada: { + ...channelStats.lazada, + repurchaseRate: + channelStats.lazada.totalCust > 0 + ? Number(((channelStats.lazada.repeatCust / channelStats.lazada.totalCust) * 100).toFixed(1)) + : 0, + }, + }, + topRepeatProducts, + customers: formattedCustomers, + pagination: { + page, + limit, + total: totalFiltered, + totalPages, + }, + }; + }) + + // ── GET Single Repeat Customer Detail & Orders History ───────────────────── + .get("/repeat-customers/detail", async ({ query }) => { + const channel = (query.channel as string) || "tiktok"; + const identifier = (query.identifier as string) || ""; + + if (!identifier) { + return { error: "Missing identifier" }; + } + + let customerInfo: any = null; + let orders: any[] = []; + + if (channel === "tiktok") { + const dbOrders = await prisma.tiktokOrder.findMany({ + where: { + buyerUsername: identifier, + }, + orderBy: { createdTime: "desc" }, + include: { + items: true, + }, + }); + + if (dbOrders.length > 0) { + const first = dbOrders[0]; + customerInfo = { + identifier, + displayName: identifier, + channel: "tiktok", + shop: first.shop, + location: [first.district, first.province].filter(Boolean).join(", ") || first.province || "", + totalOrders: dbOrders.length, + totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0), + }; + + 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, + }, + }); + + if (dbOrders.length > 0) { + const first = dbOrders[0]; + const addr = extractShopeeAddress(first); + customerInfo = { + identifier, + displayName: identifier, + channel: "shopee", + shop: first.shop, + location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "", + totalOrders: dbOrders.length, + totalSpent: dbOrders.reduce((s, o) => s + (Number(o.buyerPaidAmount || o.totalAmount) || 0), 0), + }; + + 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") { + // Identifier format for lazada is: "name_postcode" or just name + 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, + }, + }); + + if (dbOrders.length > 0) { + const first = dbOrders[0]; + const addr = extractLazadaAddress(first); + customerInfo = { + identifier, + displayName: `${first.customerName || "ลูกค้า Lazada"}${first.shippingPostCode ? ` (${first.shippingPostCode})` : ""}`, + channel: "lazada", + shop: first.shop, + location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "", + totalOrders: dbOrders.length, + totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0), + }; + + 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: customerInfo || { + identifier, + displayName: identifier, + channel, + totalOrders: 0, + totalSpent: 0, + }, + orders, + }; + }) + + // ── GET Repeat Customers Calendar View ───────────────────────────────────── + .get("/repeat-customers/calendar", async ({ query }) => { + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + + // Default to current month YYYY-MM if not provided + const now = new Date(); + const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const targetMonth = (query.month as string) || currentMonthStr; // e.g. "2026-08" + + const monthStart = `${targetMonth}-01`; + const monthEnd = `${targetMonth}-31 23:59`; + + // 1. Identify repeat buyers lifetime first + type CustIdMap = Map; + const allCustomers = new Map(); + + // TikTok + if (channel === "all" || channel === "tiktok") { + const tt = await prisma.tiktokOrder.findMany({ + where: { ...sf.tiktok }, + select: { orderId: true, buyerUsername: true }, + }); + for (const o of tt) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `TT_${o.orderId}`; + const key = `tiktok:${id.toLowerCase()}`; + const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, channel: "tiktok" }; + prev.orderCount += 1; + allCustomers.set(key, prev); + } + } + + // Shopee + if (channel === "all" || channel === "shopee") { + const sp = await prisma.shopeeOrder.findMany({ + where: { ...sf.shopee }, + select: { orderId: true, buyerUsername: true }, + }); + for (const o of sp) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `SP_${o.orderId}`; + const key = `shopee:${id.toLowerCase()}`; + const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, channel: "shopee" }; + prev.orderCount += 1; + allCustomers.set(key, prev); + } + } + + // Lazada + if (channel === "all" || channel === "lazada") { + const lz = await prisma.lazadaOrder.findMany({ + where: { ...sf.lazada }, + select: { orderNumber: true, customerName: true, shippingPostCode: true }, + }); + for (const o of lz) { + const rawName = (o.customerName || "").trim(); + const postCode = (o.shippingPostCode || "").trim(); + const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; + const key = `lazada:${id}`; + const prev = allCustomers.get(key) || { + orderCount: 0, + displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, + channel: "lazada", + }; + prev.orderCount += 1; + allCustomers.set(key, prev); + } + } + + // Set of repeat customer keys + const repeatKeys = new Set(); + for (const [k, v] of allCustomers.entries()) { + if (v.orderCount >= 2) { + repeatKeys.add(k); + } + } + + // 2. Fetch orders in target month + type CalendarDayOrder = { + orderId: string; + channel: "tiktok" | "shopee" | "lazada"; + shop: string; + customerKey: string; + customerName: string; + time: string; + amount: number; + status: string; + itemsCount: number; + topProducts: string[]; + }; + + const daysMap = new Map; + orders: CalendarDayOrder[]; + channelBreakdown: { + tiktok: { orders: number; revenue: number }; + shopee: { orders: number; revenue: number }; + lazada: { orders: number; revenue: number }; + }; + }>(); + + // Helper to add order to day + const addOrderToDay = ( + dateStr: string, + order: CalendarDayOrder + ) => { + const dayKey = dateStr.slice(0, 10); + if (!dayKey.startsWith(targetMonth)) return; + + let day = daysMap.get(dayKey); + if (!day) { + day = { + date: dayKey, + ordersCount: 0, + revenue: 0, + uniqueCustomers: new Set(), + orders: [], + channelBreakdown: { + tiktok: { orders: 0, revenue: 0 }, + shopee: { orders: 0, revenue: 0 }, + lazada: { orders: 0, revenue: 0 }, + }, + }; + daysMap.set(dayKey, day); + } + + day.ordersCount += 1; + day.revenue += order.amount; + day.uniqueCustomers.add(order.customerKey); + day.orders.push(order); + day.channelBreakdown[order.channel].orders += 1; + day.channelBreakdown[order.channel].revenue += order.amount; + }; + + // Query TikTok orders in month + if (channel === "all" || channel === "tiktok") { + const ttOrders = await prisma.tiktokOrder.findMany({ + where: { + ...sf.tiktok, + createdTime: { gte: monthStart, lte: monthEnd }, + }, + select: { + orderId: true, + shop: true, + status: true, + buyerUsername: true, + orderAmount: true, + createdTime: true, + items: { select: { productName: true, quantity: true } }, + }, + }); + + for (const o of ttOrders) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `TT_${o.orderId}`; + const key = `tiktok:${id.toLowerCase()}`; + if (!repeatKeys.has(key)) continue; // only repeat buyers + + const custInfo = allCustomers.get(key); + const dateStr = o.createdTime || ""; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + + addOrderToDay(dateStr, { + orderId: o.orderId, + channel: "tiktok", + shop: o.shop, + customerKey: key, + customerName: custInfo?.displayName || rawUser || o.orderId, + time: dateStr.slice(11, 16) || "", + amount, + status: o.status, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + topProducts: prodNames.slice(0, 2), + }); + } + } + + // Query Shopee orders in month + if (channel === "all" || channel === "shopee") { + const spOrders = await prisma.shopeeOrder.findMany({ + where: { + ...sf.shopee, + orderCreatedAt: { gte: monthStart, lte: monthEnd }, + }, + select: { + orderId: true, + shop: true, + status: true, + buyerUsername: true, + buyerPaidAmount: true, + totalAmount: true, + orderCreatedAt: true, + items: { select: { productName: true, quantity: true } }, + }, + }); + + for (const o of spOrders) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `SP_${o.orderId}`; + const key = `shopee:${id.toLowerCase()}`; + if (!repeatKeys.has(key)) continue; + + const custInfo = allCustomers.get(key); + const dateStr = o.orderCreatedAt || ""; + const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + + addOrderToDay(dateStr, { + orderId: o.orderId, + channel: "shopee", + shop: o.shop, + customerKey: key, + customerName: custInfo?.displayName || rawUser || o.orderId, + time: dateStr.slice(11, 16) || "", + amount, + status: o.status, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + topProducts: prodNames.slice(0, 2), + }); + } + } + + // Query Lazada orders in month + if (channel === "all" || channel === "lazada") { + const lzOrders = await prisma.lazadaOrder.findMany({ + where: { + ...sf.lazada, + createTime: { gte: monthStart, lte: monthEnd }, + }, + select: { + orderNumber: true, + shop: true, + status: true, + customerName: true, + shippingPostCode: true, + orderAmount: true, + createTime: true, + items: { select: { itemName: true } }, + }, + }); + + for (const o of lzOrders) { + const rawName = (o.customerName || "").trim(); + const postCode = (o.shippingPostCode || "").trim(); + const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; + const key = `lazada:${id}`; + if (!repeatKeys.has(key)) continue; + + const custInfo = allCustomers.get(key); + const dateStr = o.createTime || ""; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); + + addOrderToDay(dateStr, { + orderId: o.orderNumber, + channel: "lazada", + shop: o.shop, + customerKey: key, + customerName: custInfo?.displayName || rawName || o.orderNumber, + time: dateStr.slice(11, 16) || "", + amount, + status: o.status || "Completed", + itemsCount: o.items.length, + topProducts: prodNames.slice(0, 2), + }); + } + } + + // Format days output + let monthTotalRevenue = 0; + let monthTotalOrders = 0; + const monthUniqueCustomers = new Set(); + let peakDay = { date: "", revenue: 0, orders: 0 }; + + const days = Array.from(daysMap.values()).map((d) => { + monthTotalRevenue += d.revenue; + monthTotalOrders += d.ordersCount; + d.uniqueCustomers.forEach((c) => monthUniqueCustomers.add(c)); + + if (d.revenue > peakDay.revenue) { + peakDay = { date: d.date, revenue: Number(d.revenue.toFixed(2)), orders: d.ordersCount }; + } + + // Sort orders newest time first + d.orders.sort((a, b) => b.time.localeCompare(a.time)); + + return { + date: d.date, + day: parseInt(d.date.slice(8, 10), 10), + ordersCount: d.ordersCount, + revenue: Number(d.revenue.toFixed(2)), + uniqueCustomersCount: d.uniqueCustomers.size, + channelBreakdown: { + tiktok: { orders: d.channelBreakdown.tiktok.orders, revenue: Number(d.channelBreakdown.tiktok.revenue.toFixed(2)) }, + shopee: { orders: d.channelBreakdown.shopee.orders, revenue: Number(d.channelBreakdown.shopee.revenue.toFixed(2)) }, + lazada: { orders: d.channelBreakdown.lazada.orders, revenue: Number(d.channelBreakdown.lazada.revenue.toFixed(2)) }, + }, + orders: d.orders, + }; + }); + + return { + month: targetMonth, + summary: { + totalRepeatOrders: monthTotalOrders, + totalRepeatRevenue: Number(monthTotalRevenue.toFixed(2)), + totalUniqueRepeatCustomers: monthUniqueCustomers.size, + peakDay, + }, + days, + }; + }) + + // ── GET Repeat Customers Timeline / Journey Swimlane ────────────────────── + .get("/repeat-customers/timeline", async ({ query }) => { + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + const search = ((query.search as string) || "").trim().toLowerCase(); + const churnStatusFilter = (query.churnStatus as string) || "all"; // 'all' | 'healthy' | 'due_soon' | 'churn_risk' + const page = Math.max(1, parseInt((query.page as string) || "1", 10)); + const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20", 10))); + + type OrderNode = { + orderId: string; + channel: "tiktok" | "shopee" | "lazada"; + shop: string; + date: string; + timestamp: number; + amount: number; + itemsCount: number; + productNames: string[]; + }; + + type CustomerTimeline = { + id: string; + identifier: string; + displayName: string; + primaryChannel: "tiktok" | "shopee" | "lazada"; + location: string; + orderCount: number; + totalSpent: number; + firstOrderDate: string; + lastOrderDate: string; + firstTimestamp: number; + lastTimestamp: number; + avgIntervalDays: number; + daysSinceLastOrder: number; + predictedNextOrderDate: string; + churnStatus: "healthy" | "due_soon" | "churn_risk"; + orders: OrderNode[]; + }; + + const customerMap = new Map(); + + // 1. TikTok + if (channel === "all" || channel === "tiktok") { + const orders = await prisma.tiktokOrder.findMany({ + where: { ...sf.tiktok }, + select: { + orderId: true, + shop: true, + buyerUsername: true, + orderAmount: true, + createdTime: true, + province: true, + district: true, + items: { select: { productName: true, quantity: true } }, + }, + }); + + for (const o of orders) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `TT_${o.orderId}`; + const key = `tiktok:${id.toLowerCase()}`; + const dateStr = o.createdTime || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + const loc = [o.district, o.province].filter(Boolean).join(", ") || o.province || ""; + + let c = customerMap.get(key); + if (!c) { + c = { + id: key, + identifier: id, + displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, + primaryChannel: "tiktok", + location: loc, + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + avgIntervalDays: 0, + daysSinceLastOrder: 0, + predictedNextOrderDate: "", + churnStatus: "healthy", + orders: [], + }; + customerMap.set(key, c); + } + + c.orderCount += 1; + c.totalSpent += amount; + if (loc && !c.location) c.location = loc; + + c.orders.push({ + orderId: o.orderId, + channel: "tiktok", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + productNames: prodNames, + }); + } + } + + // 2. Shopee + if (channel === "all" || channel === "shopee") { + const orders = await prisma.shopeeOrder.findMany({ + where: { ...sf.shopee }, + select: { + orderId: true, + shop: true, + buyerUsername: true, + buyerPaidAmount: true, + totalAmount: true, + orderCreatedAt: true, + items: { select: { productName: true, quantity: true, rawData: true } }, + }, + }); + + for (const o of orders) { + const rawUser = (o.buyerUsername || "").trim(); + const id = rawUser || `SP_${o.orderId}`; + const key = `shopee:${id.toLowerCase()}`; + const dateStr = o.orderCreatedAt || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; + const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); + const { province, district } = extractShopeeAddress(o); + const loc = [district, province].filter(Boolean).join(", ") || province || ""; + + let c = customerMap.get(key); + if (!c) { + c = { + id: key, + identifier: id, + displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, + primaryChannel: "shopee", + location: loc, + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + avgIntervalDays: 0, + daysSinceLastOrder: 0, + predictedNextOrderDate: "", + churnStatus: "healthy", + orders: [], + }; + customerMap.set(key, c); + } + + c.orderCount += 1; + c.totalSpent += amount; + if (loc && !c.location) c.location = loc; + + c.orders.push({ + orderId: o.orderId, + channel: "shopee", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), + productNames: prodNames, + }); + } + } + + // 3. Lazada + if (channel === "all" || channel === "lazada") { + const orders = await prisma.lazadaOrder.findMany({ + where: { ...sf.lazada }, + select: { + orderNumber: true, + shop: true, + customerName: true, + shippingCity: true, + shippingRegion: true, + shippingPostCode: true, + orderAmount: true, + createTime: true, + items: { select: { itemName: true } }, + }, + }); + + for (const o of orders) { + const rawName = (o.customerName || "").trim(); + const postCode = (o.shippingPostCode || "").trim(); + const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; + const key = `lazada:${id}`; + const dateStr = o.createTime || ""; + const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; + const amount = Number(o.orderAmount) || 0; + const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); + const { province, district } = extractLazadaAddress(o); + const loc = [district, province].filter(Boolean).join(", ") || province || ""; + + let c = customerMap.get(key); + if (!c) { + c = { + id: key, + identifier: id, + displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, + primaryChannel: "lazada", + location: loc, + orderCount: 0, + totalSpent: 0, + firstOrderDate: dateStr, + lastOrderDate: dateStr, + firstTimestamp: ts, + lastTimestamp: ts, + avgIntervalDays: 0, + daysSinceLastOrder: 0, + predictedNextOrderDate: "", + churnStatus: "healthy", + orders: [], + }; + customerMap.set(key, c); + } + + c.orderCount += 1; + c.totalSpent += amount; + if (loc && !c.location) c.location = loc; + + c.orders.push({ + orderId: o.orderNumber, + channel: "lazada", + shop: o.shop, + date: dateStr, + timestamp: ts, + amount, + itemsCount: o.items.length, + productNames: prodNames, + }); + } + } + + // 4. Filter only repeat buyers (>= 2 orders), calculate cycles & churn + const nowTime = Date.now(); + let healthyCount = 0; + let dueSoonCount = 0; + let churnRiskCount = 0; + + const repeatCustomers: CustomerTimeline[] = []; + + for (const c of customerMap.values()) { + if (c.orderCount < 2) continue; + + // Sort orders chronologically + c.orders.sort((a, b) => a.timestamp - b.timestamp); + + c.firstTimestamp = c.orders[0].timestamp; + c.firstOrderDate = c.orders[0].date; + c.lastTimestamp = c.orders[c.orders.length - 1].timestamp; + c.lastOrderDate = c.orders[c.orders.length - 1].date; + + if (c.lastTimestamp > c.firstTimestamp) { + const totalSpanDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24); + c.avgIntervalDays = Number((totalSpanDays / (c.orderCount - 1)).toFixed(1)); + } else { + c.avgIntervalDays = 1; + } + + const daysSince = Math.max(0, (nowTime - c.lastTimestamp) / (1000 * 60 * 60 * 24)); + c.daysSinceLastOrder = Number(daysSince.toFixed(1)); + + // Predicted next purchase + const predictedTs = c.lastTimestamp + c.avgIntervalDays * 24 * 60 * 60 * 1000; + c.predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10); + + // Churn status: + // healthy: daysSince <= avgInterval + // due_soon: daysSince > avgInterval && daysSince <= avgInterval * 1.5 + // churn_risk: daysSince > avgInterval * 1.5 + if (c.daysSinceLastOrder <= c.avgIntervalDays) { + c.churnStatus = "healthy"; + healthyCount += 1; + } else if (c.daysSinceLastOrder <= c.avgIntervalDays * 1.5) { + c.churnStatus = "due_soon"; + dueSoonCount += 1; + } else { + c.churnStatus = "churn_risk"; + churnRiskCount += 1; + } + + repeatCustomers.push(c); + } + + // 5. Filter & Sort + let filtered = repeatCustomers; + + if (churnStatusFilter !== "all") { + filtered = filtered.filter((c) => c.churnStatus === churnStatusFilter); + } + + if (search) { + filtered = filtered.filter( + (c) => + c.displayName.toLowerCase().includes(search) || + c.identifier.toLowerCase().includes(search) || + c.location.toLowerCase().includes(search) + ); + } + + // Sort: prioritizes due_soon and healthy active, then orders count + filtered.sort((a, b) => { + const churnWeight = { due_soon: 3, healthy: 2, churn_risk: 1 }; + const diff = churnWeight[b.churnStatus] - churnWeight[a.churnStatus]; + if (diff !== 0) return diff; + return b.orderCount - a.orderCount || b.totalSpent - a.totalSpent; + }); + + const totalFiltered = filtered.length; + const totalPages = Math.ceil(totalFiltered / limit); + const paginated = filtered.slice((page - 1) * limit, page * limit); + + return { + summary: { + totalRepeatCustomers: repeatCustomers.length, + healthyCount, + dueSoonCount, + churnRiskCount, + }, + customers: paginated, + pagination: { + page, + limit, + total: totalFiltered, + totalPages, + }, + }; });