This commit is contained in:
Your Name committed 2026-09-09 06:46:12 +07:00
1 parent f8e11d5568
commit 8e35cb48d1
13 files changed
+3846 -1

No files matched your search

+6
View File
@@ -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() {
<Route path="/business-analytics/sales-map-products" element={<ProductSalesMapPage />} />
<Route path="/business-analytics/malaysia-map" element={<MalaysiaSalesMapPage />} />
<Route path="/business-analytics/vietnam-map" element={<VietnamSalesMapPage />} />
<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 />} />
{/* Tools */}
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
+6
View File
@@ -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 = [
@@ -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<OrderHistory[]>([]);
const [customerDetails, setCustomerDetails] = useState<any>(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 (
<span className="px-2.5 py-0.5 rounded-full 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-0.5 rounded-full 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-0.5 rounded-full 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-0.5 rounded-full text-xs font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
{channel}
</span>
);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-3.5">
<div className="w-11 h-11 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">
<ShoppingBag className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-lg font-bold text-white tracking-tight">
{customer.displayName}
</h2>
{getChannelBadge(customer.primaryChannel)}
</div>
<div className="flex items-center gap-4 text-xs text-slate-400 mt-1">
{customer.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
{customer.location}
</span>
)}
<span className="flex items-center gap-1 text-emerald-400 font-medium">
<DollarSign className="w-3.5 h-3.5" />
ยอดสะสม: {fmtBaht(customerDetails?.totalSpent || customer.totalSpent || 0)}
</span>
<span className="flex items-center gap-1 text-indigo-300 font-medium">
<Package className="w-3.5 h-3.5" />
{customerDetails?.totalOrders || customer.orderCount || 0} คำสั่งซื้อ
</span>
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-4">
{loading ? (
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-400">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm">กำลังโหลดประวัติคำสั่งซื้อ...</p>
</div>
) : orders.length === 0 ? (
<div className="py-16 text-center text-slate-400">
<ShoppingBag className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-base font-medium text-slate-300">ไม่พบข้อมูลประวัติคำสั่งซื้อ</p>
<p className="text-xs text-slate-500 mt-1">คำสั่งซื้ออาจถูกลบหรือไม่มีข้อมูลรายการสินค้า</p>
</div>
) : (
<div className="space-y-4">
<div className="text-xs font-semibold text-slate-400 tracking-wider uppercase flex items-center justify-between">
<span>ไทม์ไลน์คำสั่งซื้อทั้งหมด ({orders.length} รายการ)</span>
<span className="text-[11px] font-normal text-slate-500">เรียงจากล่าสุดไปอดีต</span>
</div>
{orders.map((order, idx) => (
<div
key={order.orderId || idx}
className="bg-slate-950/60 border border-white/10 rounded-xl p-4 transition-all hover:border-indigo-500/30"
>
{/* Order Top Bar */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-3 border-b border-white/5">
<div className="flex items-center gap-2">
<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>
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/5">
{order.shop}
</span>
</div>
<div className="flex items-center gap-2.5">
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-white/10">
{order.status || 'สำเร็จ'}
</span>
<span className="text-sm font-bold text-emerald-400">
{fmtBaht(order.amount)}
</span>
</div>
</div>
{/* Order Meta */}
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-400 mt-2.5">
{order.date && (
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
{order.date}
</span>
)}
{order.trackingId && (
<span className="flex items-center gap-1 font-mono text-slate-300">
<Tag className="w-3.5 h-3.5 text-slate-500" />
Tracking: {order.trackingId}
</span>
)}
{order.shippingFee > 0 && (
<span className="text-slate-500">
ค่าจัดส่ง: {fmtBaht(order.shippingFee)}
</span>
)}
</div>
{/* Order Items */}
{order.items && order.items.length > 0 && (
<div className="mt-3.5 pt-3 border-t border-white/5 space-y-2">
{order.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-center justify-between text-xs bg-white/[0.02] p-2 rounded-lg border border-white/5"
>
<div className="flex items-center gap-2 min-w-0 pr-2">
<Package className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
<span className="font-medium text-slate-200 truncate">
{item.name}
</span>
{item.variation && (
<span className="text-slate-400 bg-white/5 px-1.5 py-0.5 rounded text-[11px] shrink-0">
{item.variation}
</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-slate-400 font-mono">
x{item.quantity}
</span>
<span className="text-slate-300 font-semibold font-mono">
{fmtBaht(item.price)}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3.5 border-t border-white/10 bg-white/[0.02] flex items-center justify-between">
<span className="text-xs text-slate-400">
คำนวณจากประวัติคำสั่งซื้อในระบบ HomAroma
</span>
<button
onClick={onClose}
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-sm font-medium text-white transition-all"
>
ปิดหน้าต่าง
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="flex items-center gap-2 p-1.5 bg-slate-950/70 border border-white/10 rounded-2xl backdrop-blur-md overflow-x-auto">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
`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'
}`
}
>
<Icon className="w-3.5 h-3.5" />
<span>{tab.label}</span>
{tab.badge && (
<span className="px-1.5 py-0.2 rounded text-[10px] font-mono font-normal bg-white/15 text-indigo-200">
{tab.badge}
</span>
)}
</NavLink>
);
})}
</div>
);
}
@@ -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."
}
}
@@ -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) を可視化し、離脱防止に向けた次回購入時期を予測"
}
}
@@ -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"
}
}
@@ -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) 并预测下次购买时间,防止客户流失"
}
}
@@ -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<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
const [loading, setLoading] = useState(true);
const [calendarData, setCalendarData] = useState<any>(null);
// Selected Day for Drawer
const [selectedDayData, setSelectedDayData] = useState<any>(null);
// Selected Customer for Detail Modal
const [selectedCustomerForModal, setSelectedCustomerForModal] = useState<any>(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<number, any>();
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 (
<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">
<CalendarIcon className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{t('repeatCustomersCalendar.title', 'ปฏิทินการซื้อซ้ำ (Repeat Customers Calendar)')}
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
{t(
'repeatCustomersCalendar.subtitle',
'ตรวจสอบจังหวะและยอดคำสั่งซื้อซ้ำรายวัน และวิเคราะห์วันที่มีการซื้อซ้ำสูงสุด (Peak Days)'
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchData()}
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' : ''}`} />
{t('common.refresh', 'รีเฟรช')}
</button>
</div>
</div>
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
<RepeatCustomersNavTabs />
{/* ── Month Controls & Filter Bar ─────────────────────────────── */}
<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-4">
{/* Month Navigator */}
<div className="flex items-center gap-3">
<button
onClick={prevMonth}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/5 transition-all"
title="เดือนก่อนหน้า"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-2">
<h2 className="text-base sm:text-lg font-bold text-white tracking-tight">
{monthNamesTh[month]} {year + 543}
</h2>
<button
onClick={goToToday}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] font-medium text-slate-400 hover:text-white border border-white/5 transition-all"
>
เดือนปัจจุบัน
</button>
</div>
<button
onClick={nextMonth}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/5 transition-all"
title="เดือนถัดไป"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Channel & Status Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Channel selection */}
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
<button
onClick={() => setChannel('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'all'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
ทุกช่องทาง
</button>
<button
onClick={() => setChannel('tiktok')}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'tiktok'
? 'bg-rose-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
TikTok
</button>
<button
onClick={() => setChannel('shopee')}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'shopee'
? 'bg-orange-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
Shopee
</button>
<button
onClick={() => setChannel('lazada')}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'lazada'
? 'bg-blue-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
Lazada
</button>
</div>
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-xl px-2.5 py-2 focus:outline-none focus:border-indigo-500"
>
<option value="completed">เฉพาะสำเร็จ</option>
<option value="all">ทั้งหมด (รวมยกเลิก)</option>
<option value="cancelled">เฉพาะยกเลิก</option>
</select>
</div>
</div>
{/* ── Monthly KPI Summary ────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 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 text-slate-400">ยอดขายซื้อซ้ำประจำเดือน</span>
<DollarSign className="w-4 h-4 text-amber-400" />
</div>
<div className="mt-2 text-xl font-bold text-amber-400 font-mono">
{fmtBaht(summary.totalRepeatRevenue)}
</div>
</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 text-slate-400">ออเดอร์ซื้อซ้ำในเดือนนี้</span>
<ShoppingBag className="w-4 h-4 text-purple-400" />
</div>
<div className="mt-2 text-xl font-bold text-purple-400 font-mono">
{fmtNum(summary.totalRepeatOrders)} <span className="text-xs font-normal text-slate-500">ออเดอร์</span>
</div>
</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 text-slate-400">ลูกค้าที่กลับมาซื้อซ้ำ</span>
<Users className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2 text-xl font-bold text-emerald-400 font-mono">
{fmtNum(summary.totalUniqueRepeatCustomers)} <span className="text-xs font-normal text-slate-500">คน</span>
</div>
</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 text-slate-400">วันซื้อซ้ำสูงสุด (Peak Day)</span>
<Flame className="w-4 h-4 text-rose-400" />
</div>
<div className="mt-2 text-sm font-bold text-rose-400">
{summary.peakDay?.date ? (
<>
<span>วันที่ {summary.peakDay.date.slice(8, 10)}</span>
<span className="text-xs text-slate-300 font-mono block font-normal mt-0.5">
{fmtBaht(summary.peakDay.revenue)} ({summary.peakDay.orders} ออเดอร์)
</span>
</>
) : (
<span className="text-slate-500">-</span>
)}
</div>
</div>
</div>
{/* ── Calendar Grid ─────────────────────────────────────────── */}
<div className="bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md p-4 sm:p-5 overflow-hidden">
{/* Weekday headers */}
<div className="grid grid-cols-7 gap-2 text-center text-xs font-semibold text-slate-400 pb-3 border-b border-white/10">
{weekdayNames.map((w, idx) => (
<div key={idx} className={idx >= 5 ? 'text-amber-400/80' : ''}>
{w}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7 gap-2 mt-3">
{/* Empty cells before month start */}
{Array.from({ length: firstDayIndex }).map((_, idx) => (
<div key={`empty-${idx}`} className="min-h-[95px] sm:min-h-[115px] rounded-xl bg-white/[0.01] border border-transparent opacity-30" />
))}
{/* 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 (
<div
key={`day-${dayNum}`}
onClick={() => {
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 */}
<div className="flex items-center justify-between">
<span className={`text-xs sm:text-sm font-bold ${hasData ? 'text-white' : 'text-slate-500'}`}>
{dayNum}
</span>
{hasData && (
<span className="px-1.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
{dayData.ordersCount}
</span>
)}
</div>
{/* Bottom: Revenue & channel indicators */}
{hasData ? (
<div className="mt-2 space-y-1">
<div className="text-[11px] sm:text-xs font-bold text-amber-300 font-mono">
{fmtBaht(dayData.revenue)}
</div>
<div className="flex items-center gap-1">
{dayData.channelBreakdown.tiktok.orders > 0 && (
<span className="w-2 h-2 rounded-full bg-rose-400" title={`TikTok: ${dayData.channelBreakdown.tiktok.orders} ออเดอร์`} />
)}
{dayData.channelBreakdown.shopee.orders > 0 && (
<span className="w-2 h-2 rounded-full bg-orange-400" title={`Shopee: ${dayData.channelBreakdown.shopee.orders} ออเดอร์`} />
)}
{dayData.channelBreakdown.lazada.orders > 0 && (
<span className="w-2 h-2 rounded-full bg-blue-400" title={`Lazada: ${dayData.channelBreakdown.lazada.orders} ออเดอร์`} />
)}
</div>
</div>
) : (
<div className="text-[10px] text-slate-700">ไม่มีคำสั่งซื้อ</div>
)}
</div>
);
})}
</div>
</div>
{/* ── Daily Orders Side Drawer / Modal ───────────────────────── */}
{selectedDayData && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/70 backdrop-blur-sm animate-fade-in">
<div className="w-full max-w-xl bg-slate-900 border-l border-white/10 h-full flex flex-col shadow-2xl overflow-hidden">
{/* Drawer Header */}
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-white/[0.02]">
<div>
<h3 className="text-lg font-bold text-white tracking-tight">
คำสั่งซื้อซ้ำ — วันที่ {selectedDayData.day} {monthNamesTh[month]}
</h3>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
<span className="text-amber-400 font-semibold font-mono">
ยอดรวม {fmtBaht(selectedDayData.revenue)}
</span>
<span>•</span>
<span>{selectedDayData.ordersCount} คำสั่งซื้อ</span>
<span>•</span>
<span>{selectedDayData.uniqueCustomersCount} ลูกค้า</span>
</div>
</div>
<button
onClick={() => setSelectedDayData(null)}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Orders List */}
<div className="p-5 overflow-y-auto flex-1 space-y-3">
{selectedDayData.orders.map((order: any, idx: number) => {
const getBadge = (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>;
};
return (
<div
key={idx}
className="p-3.5 rounded-xl bg-slate-950/60 border border-white/5 space-y-2 hover:border-indigo-500/30 transition-all"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{getBadge(order.channel)}
<span className="font-semibold text-sm text-slate-200">
{order.customerName}
</span>
</div>
<span className="text-sm font-bold text-amber-400 font-mono">
{fmtBaht(order.amount)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-mono text-slate-500">#{order.orderId}</span>
{order.time && (
<span className="flex items-center gap-1 text-slate-500 font-mono">
<Clock className="w-3 h-3" />
{order.time} น.
</span>
)}
</div>
{order.topProducts?.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1 border-t border-white/5">
{order.topProducts.map((p: string, pIdx: number) => (
<span
key={pIdx}
className="px-1.5 py-0.5 rounded bg-white/5 text-slate-300 text-[10px] truncate max-w-[200px]"
>
{p}
</span>
))}
</div>
)}
<div className="pt-2 flex justify-end">
<button
onClick={() => {
setSelectedCustomerForModal({
id: order.customerKey,
identifier: order.customerKey.split(':')[1] || order.customerKey,
displayName: order.customerName,
primaryChannel: order.channel,
});
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium transition-all"
>
<ExternalLink className="w-3 h-3" />
ดูประวัติลูกค้าคนนี้
</button>
</div>
</div>
);
})}
</div>
{/* Drawer Footer */}
<div className="p-4 border-t border-white/10 bg-white/[0.01] flex justify-end">
<button
onClick={() => setSelectedDayData(null)}
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-xs font-medium text-white transition-all"
>
ปิดหน้าต่าง
</button>
</div>
</div>
</div>
)}
{/* ── Customer Detail Orders Modal ────────────────────────────── */}
{selectedCustomerForModal && (
<RepeatCustomerOrdersModal
customer={selectedCustomerForModal}
onClose={() => setSelectedCustomerForModal(null)}
/>
)}
</div>
);
}
@@ -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<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
const [period, setPeriod] = useState<TimePeriod>('all');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [minOrders, setMinOrders] = useState<number>(2); // default: repeat customers (>= 2)
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [sortBy, setSortBy] = useState<SortBy>('orders');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(25);
// Data state
const [loading, setLoading] = useState(true);
const [data, setData] = useState<any>(null);
const [selectedCustomer, setSelectedCustomer] = useState<any>(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 (
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
TikTok
</span>
);
case 'shopee':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
Shopee
</span>
);
case 'lazada':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
Lazada
</span>
);
default:
return (
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
{ch}
</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('repeatCustomers.title', 'ลูกค้าซื้อซ้ำ (Repeat Customers)')}
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
{t(
'repeatCustomers.subtitle',
'วิเคราะห์พฤติกรรมการซื้อซ้ำของลูกค้าจาก Shopee, Lazada และ TikTok พร้อมตัวชี้วัด Repurchase Rate & LTV'
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => fetchData()}
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' : ''}`} />
{t('common.refresh', 'รีเฟรช')}
</button>
<button
onClick={handleExportCsv}
disabled={loading || !data?.customers?.length}
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 border border-indigo-500/30 text-xs font-semibold text-white shadow-lg shadow-indigo-600/20 transition-all disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
{t('repeatCustomers.exportCsv', 'Export CSV')}
</button>
</div>
</div>
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
<RepeatCustomersNavTabs />
{/* ── Filters & Controls Bar ─────────────────────────────────── */}
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md space-y-4">
{/* Channel Selection Tabs */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
<button
onClick={() => {
setChannel('all');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'all'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{t('repeatCustomers.allChannels', 'ทุกช่องทาง (All Channels)')}
</button>
<button
onClick={() => {
setChannel('tiktok');
setPage(1);
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'tiktok'
? 'bg-rose-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-400" />
TikTok Shop
</button>
<button
onClick={() => {
setChannel('shopee');
setPage(1);
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'shopee'
? 'bg-orange-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<span className="w-2 h-2 rounded-full bg-orange-400" />
Shopee
</button>
<button
onClick={() => {
setChannel('lazada');
setPage(1);
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'lazada'
? 'bg-blue-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<span className="w-2 h-2 rounded-full bg-blue-400" />
Lazada
</button>
</div>
{/* Min Orders Filter Toggle */}
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
<button
onClick={() => {
setMinOrders(2);
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
minOrders === 2
? 'bg-purple-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{t('repeatCustomers.repeatOnly', 'เฉพาะลูกค้าซื้อซ้ำ (≥ 2 ครั้ง)')}
</button>
<button
onClick={() => {
setMinOrders(1);
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
minOrders === 1
? 'bg-purple-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{t('repeatCustomers.allBuyers', 'ลูกค้าทั้งหมด (≥ 1 ครั้ง)')}
</button>
</div>
</div>
{/* Secondary Filter Row */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-white/5">
{/* Period Presets */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
{(
[
{ 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) => (
<button
key={p.key}
onClick={() => handlePeriodChange(p.key)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-all ${
period === p.key
? 'bg-white/15 text-white border border-white/20'
: 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent'
}`}
>
{p.label}
</button>
))}
</div>
{/* Custom Date Picker inputs */}
{period === 'custom' && (
<div className="flex items-center gap-2">
<input
type="date"
value={dateFrom}
onChange={(e) => {
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"
/>
<span className="text-slate-500 text-xs">ถึง</span>
<input
type="date"
value={dateTo}
onChange={(e) => {
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"
/>
</div>
)}
{/* Status Filter & Search Box */}
<div className="flex items-center gap-2 flex-1 max-w-md">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as StatusFilter);
setPage(1);
}}
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-xl px-2.5 py-1.5 focus:outline-none focus:border-indigo-500 shrink-0"
>
<option value="completed">เฉพาะคำสั่งซื้อสำเร็จ</option>
<option value="all">คำสั่งซื้อทั้งหมด (รวมยกเลิก)</option>
<option value="cancelled">เฉพาะที่ยกเลิก</option>
</select>
<div className="relative flex-1">
<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="ค้นหาชื่อลูกค้า, 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"
/>
</div>
</div>
</div>
</div>
{/* ── KPI Summary Cards ─────────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3.5 sm:gap-4">
{/* Card 1: Total Unique Customers */}
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-white/20 transition-all">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">
{t('repeatCustomers.kpiTotalCustomers', 'ลูกค้าทั้งหมด')}
</span>
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400">
<Users className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<span className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{fmtNum(summary.totalCustomers)}
</span>
<span className="text-xs text-slate-500 ml-1.5">คน</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
ลูกค้าไม่ซ้ำ (Unique Customers)
</p>
</div>
{/* Card 2: Repeat Customers Count */}
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-purple-500/30 transition-all">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-purple-300">
{t('repeatCustomers.kpiRepeatCustomers', 'ลูกค้าซื้อซ้ำ')}
</span>
<div className="w-8 h-8 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<UserCheck className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<span className="text-xl sm:text-2xl font-bold text-purple-400 tracking-tight">
{fmtNum(summary.repeatCustomers)}
</span>
<span className="text-xs text-slate-500 ml-1.5">คน</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
สั่งซื้อตั้งแต่ 2 ครั้งขึ้นไป
</p>
</div>
{/* Card 3: Repurchase Rate % */}
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-emerald-500/30 transition-all">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-emerald-300">
{t('repeatCustomers.kpiRepurchaseRate', 'อัตราการซื้อซ้ำ')}
</span>
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
<Repeat className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<span className="text-xl sm:text-2xl font-bold text-emerald-400 tracking-tight">
{summary.repurchaseRate}%
</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
สัดส่วนลูกค้าที่กลับมาซื้อซ้ำ
</p>
</div>
{/* Card 4: Repeat Revenue */}
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-amber-500/30 transition-all">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-amber-300">
{t('repeatCustomers.kpiRepeatRevenue', 'ยอดขายจากลูกค้าซื้อซ้ำ')}
</span>
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
<DollarSign className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<span className="text-lg sm:text-xl font-bold text-amber-400 tracking-tight">
{fmtBaht(summary.repeatRevenue)}
</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
คิดเป็น {summary.repeatRevenueShare}% ของยอดขายรวม
</p>
</div>
{/* Card 5: Average Days to Repurchase */}
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-indigo-500/30 transition-all col-span-2 md:col-span-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-indigo-300">
{t('repeatCustomers.kpiAvgInterval', 'รอบเวลาซื้อซ้ำเฉลี่ย')}
</span>
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400">
<Clock className="w-4 h-4" />
</div>
</div>
<div className="mt-3">
<span className="text-xl sm:text-2xl font-bold text-indigo-400 tracking-tight">
{summary.avgRepurchaseIntervalDays > 0 ? `${summary.avgRepurchaseIntervalDays}` : '-'}
</span>
<span className="text-xs text-slate-500 ml-1.5">วัน</span>
</div>
<p className="text-[11px] text-slate-400 mt-1">
ระยะห่างเฉลี่ยระหว่างการซื้อซ้ำ
</p>
</div>
</div>
{/* ── Cohorts & Top Products Grid (2 Columns) ────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
{/* Col 1: Frequency Cohorts (7 cols) */}
<div className="lg:col-span-7 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" />
<h3 className="text-sm font-semibold text-white">
{t('repeatCustomers.frequencyDistribution', 'การแจกแจงความถี่ในการสั่งซื้อ (Purchase Frequency)')}
</h3>
</div>
<span className="text-xs text-slate-500">จำแนกตามจำนวนออเดอร์</span>
</div>
<div className="space-y-3.5">
{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 (
<div key={idx} className="bg-slate-950/40 p-3 rounded-xl border border-white/5 space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className={`font-semibold ${textColors[idx % 4]}`}>
{cohort.label}
</span>
<div className="flex items-center gap-3">
<span className="text-slate-400">
{fmtNum(cohort.count)} คน ({cohort.percentage}%)
</span>
<span className="font-semibold text-slate-200">
{fmtBaht(cohort.revenue)}
</span>
</div>
</div>
{/* Progress bar */}
<div className="w-full bg-slate-800/80 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full ${colors[idx % 4]} transition-all duration-500`}
style={{ width: `${Math.min(100, cohort.percentage || 0)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
{/* Channel stats footer */}
{data?.channelBreakdown && (
<div className="mt-4 pt-3.5 border-t border-white/5 grid grid-cols-3 gap-2 text-center text-xs">
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
<span className="text-rose-400 font-semibold block">TikTok Shop</span>
<span className="text-slate-300 font-bold block mt-0.5">
{data.channelBreakdown.tiktok?.repurchaseRate}%
</span>
<span className="text-[10px] text-slate-500">
{fmtNum(data.channelBreakdown.tiktok?.repeatCust || 0)} คนซื้อซ้ำ
</span>
</div>
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
<span className="text-orange-400 font-semibold block">Shopee</span>
<span className="text-slate-300 font-bold block mt-0.5">
{data.channelBreakdown.shopee?.repurchaseRate}%
</span>
<span className="text-[10px] text-slate-500">
{fmtNum(data.channelBreakdown.shopee?.repeatCust || 0)} คนซื้อซ้ำ
</span>
</div>
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
<span className="text-blue-400 font-semibold block">Lazada</span>
<span className="text-slate-300 font-bold block mt-0.5">
{data.channelBreakdown.lazada?.repurchaseRate}%
</span>
<span className="text-[10px] text-slate-500">
{fmtNum(data.channelBreakdown.lazada?.repeatCust || 0)} คนซื้อซ้ำ
</span>
</div>
</div>
)}
</div>
{/* Col 2: Top Repurchased Products (5 cols) */}
<div className="lg:col-span-5 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Award className="w-4 h-4 text-amber-400" />
<h3 className="text-sm font-semibold text-white">
{t('repeatCustomers.topRepeatProducts', 'สินค้ายอดฮิตที่ลูกค้าซื้อซ้ำมากที่สุด')}
</h3>
</div>
<span className="text-xs text-slate-500">Top 10 SKUs</span>
</div>
<div className="space-y-2 max-h-[330px] overflow-y-auto pr-1">
{!data?.topRepeatProducts || data.topRepeatProducts.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs">
ไม่มีข้อมูลสินค้าที่ถูกซื้อซ้ำในช่วงเวลานี้
</div>
) : (
data.topRepeatProducts.map((p: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between p-2.5 rounded-xl bg-slate-950/40 border border-white/5 text-xs hover:border-white/10 transition-all"
>
<div className="flex items-center gap-2.5 min-w-0 pr-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center font-bold text-[11px] shrink-0 ${
idx === 0
? 'bg-amber-500 text-slate-950'
: idx === 1
? 'bg-slate-300 text-slate-950'
: idx === 2
? 'bg-amber-700 text-white'
: 'bg-white/10 text-slate-400'
}`}
>
{idx + 1}
</span>
<span className="font-medium text-slate-200 truncate">
{p.productName}
</span>
</div>
<div className="flex items-center gap-2 shrink-0 text-right">
<span className="text-emerald-400 font-semibold font-mono">
{fmtNum(p.repeatBuyerCount)} คน
</span>
<span className="text-slate-500 text-[11px]">
({fmtNum(p.orderAppearances)} ออเดอร์)
</span>
</div>
</div>
))
)}
</div>
</div>
<p className="text-[11px] text-slate-500 mt-3 pt-2.5 border-t border-white/5">
แนะนำให้นำสินค้าเหล่านี้ไปทำแคมเปญ Re-order และโปรโมชั่นซื้อซ้ำ (Bundle/Subscription)
</p>
</div>
</div>
{/* ── Repeat Customers Table ─────────────────────────────────── */}
<div className="bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md overflow-hidden">
{/* Table Top Controls */}
<div className="p-4 sm:p-5 border-b border-white/10 flex flex-wrap items-center justify-between gap-3 bg-white/[0.01]">
<div className="flex items-center gap-2.5">
<UserCheck className="w-5 h-5 text-indigo-400" />
<div>
<h3 className="text-base font-bold text-white">
{t('repeatCustomers.tableTitle', 'รายชื่อลูกค้าและประวัติการซื้อซ้ำ')}
</h3>
<p className="text-xs text-slate-400">
พบทั้งหมด {fmtNum(data?.pagination?.total || 0)} รายการ
</p>
</div>
</div>
{/* Sort By selector */}
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400 flex items-center gap-1">
<Filter className="w-3.5 h-3.5" />
จัดเรียงตาม:
</span>
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value as SortBy);
setPage(1);
}}
className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-xl px-3 py-1.5 focus:outline-none focus:border-indigo-500"
>
<option value="orders">จำนวนออเดอร์สูงสุด</option>
<option value="totalSpent">ยอดใช้จ่ายสะสมสูงสุด (LTV)</option>
<option value="lastOrderDate">วันที่สั่งซื้อล่าสุด</option>
<option value="daysBetween">รอบเวลาซื้อซ้ำสั้นที่สุด</option>
</select>
</div>
</div>
{/* Table View */}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-white/10 bg-white/[0.02] text-slate-400 font-semibold">
<th className="py-3.5 px-4">#</th>
<th className="py-3.5 px-4">ลูกค้า (Identifier / Name)</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 text-right">ยอดซื้อสะสม (LTV)</th>
<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 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={10} 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">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
</td>
</tr>
) : (
data.customers.map((cust: any, idx: number) => {
const rowNumber = (page - 1) * limit + idx + 1;
return (
<tr
key={cust.id || idx}
className="hover:bg-white/[0.02] transition-colors"
>
<td className="py-3 px-4 font-mono text-slate-500">
{rowNumber}
</td>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-100 text-sm">
{cust.displayName}
</span>
{cust.location && (
<span className="text-[11px] text-slate-400 mt-0.5">
{cust.location}
</span>
)}
</div>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-1 flex-wrap">
{getChannelBadge(cust.primaryChannel)}
{cust.shops?.length > 1 && (
<span className="text-[10px] text-slate-500 bg-white/5 px-1 rounded">
+{cust.shops.length - 1} ร้าน
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-center">
<span
className={`inline-flex items-center justify-center px-2.5 py-1 rounded-full font-bold text-xs font-mono ${
cust.orderCount >= 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} ครั้ง
</span>
</td>
<td className="py-3 px-4 text-right font-bold text-slate-200 font-mono text-sm">
{fmtBaht(cust.totalSpent)}
</td>
<td className="py-3 px-4 text-right font-medium text-slate-400 font-mono">
{fmtBaht(cust.avgOrderValue)}
</td>
<td className="py-3 px-4 text-slate-300">
<div className="flex flex-col">
<span className="font-medium">
{cust.lastOrderDate?.slice(0, 10) || '-'}
</span>
<span className="text-[10px] text-slate-500">
แรกเริ่ม: {cust.firstOrderDate?.slice(0, 10) || '-'}
</span>
</div>
</td>
<td className="py-3 px-4 text-center">
{cust.daysBetweenOrders > 0 ? (
<span className="text-indigo-400 font-mono font-medium">
{cust.daysBetweenOrders} วัน
</span>
) : (
<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) => (
<span
key={pIdx}
className="px-1.5 py-0.5 rounded bg-white/5 text-slate-300 text-[10px] truncate max-w-[180px] border border-white/5"
title={prod}
>
{prod}
</span>
))}
</div>
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => setSelectedCustomer(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>
);
})
)}
</tbody>
</table>
</div>
{/* Table Pagination Bar */}
{data?.pagination && data.pagination.totalPages > 1 && (
<div className="p-4 border-t border-white/10 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400 bg-white/[0.01]">
<div className="flex items-center gap-3">
<span>
กำลังแสดงหน้า <span className="font-semibold text-white">{page}</span> จาก{' '}
<span className="font-semibold text-white">{data.pagination.totalPages}</span> หน้า
</span>
<div className="flex items-center gap-1.5 text-slate-500">
<span>แสดง:</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setPage(1);
}}
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-lg px-2 py-1 focus:outline-none focus:border-indigo-500"
>
<option value={25}>25 แถว</option>
<option value={50}>50 แถว</option>
<option value={100}>100 แถว</option>
</select>
</div>
</div>
<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} / {data.pagination.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.pagination.totalPages, p + 1))}
disabled={page >= data.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>
)}
</div>
{/* ── Order History Modal ─────────────────────────────────────── */}
{selectedCustomer && (
<RepeatCustomerOrdersModal
customer={selectedCustomer}
onClose={() => setSelectedCustomer(null)}
/>
)}
</div>
);
}
@@ -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<ChannelType>('all');
const [churnStatus, setChurnStatus] = useState<ChurnStatus>('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<any>(null);
const [selectedCustomer, setSelectedCustomer] = useState<any>(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 (
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30 animate-pulse">
<AlertCircle className="w-3 h-3 text-amber-400" />
ถึงรอบซื้อซ้ำ (Due Soon)
</span>
);
case 'healthy':
return (
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
ซื้อตามรอบปกติ (Active)
</span>
);
case 'churn_risk':
return (
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" />
เกินรอบซื้อ (Churn Risk)
</span>
);
default:
return null;
}
};
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>;
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>;
};
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">
<Clock className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{t('repeatCustomersTimeline.title', 'ไทม์ไลน์รอบการซื้อซ้ำ (Repeat Customers Timeline)')}
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
{t(
'repeatCustomersTimeline.subtitle',
'วิเคราะห์วงรอบการกลับมาซื้อ (Purchase Cycles) และคาดการณ์กำหนดซื้อซ้ำรอบถัดไปเพื่อป้องกันการ Churn'
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fetchData()}
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' : ''}`} />
{t('common.refresh', 'รีเฟรช')}
</button>
</div>
</div>
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
<RepeatCustomersNavTabs />
{/* ── Churn Risk & Retention Summary KPI Cards ───────────────── */}
<div className="grid grid-cols-2 md:grid-cols-4 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 text-slate-400">ลูกค้าซื้อซ้ำทั้งหมด</span>
<Users className="w-4 h-4 text-indigo-400" />
</div>
<div className="mt-2 text-xl font-bold text-white font-mono">
{fmtNum(summary.totalRepeatCustomers)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-slate-500 mt-0.5">ลูกค้าที่มี ≥ 2 ออเดอร์</p>
</div>
{/* Due Soon Card */}
<div
onClick={() => {
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'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-amber-300">ถึงรอบซื้อซ้ำ (Due Soon)</span>
<AlertCircle className="w-4 h-4 text-amber-400" />
</div>
<div className="mt-2 text-xl font-bold text-amber-400 font-mono">
{fmtNum(summary.dueSoonCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-amber-400/80 mt-0.5">โอกาสทองในการส่งโปรโมชัน</p>
</div>
{/* Healthy Card */}
<div
onClick={() => {
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'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-emerald-300">ซื้อตามรอบปกติ (Active)</span>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2 text-xl font-bold text-emerald-400 font-mono">
{fmtNum(summary.healthyCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-emerald-400/80 mt-0.5">ระยะเวลายังไม่เกินรอบเฉลี่ย</p>
</div>
{/* Churn Risk Card */}
<div
onClick={() => {
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'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-rose-300">เสี่ยงหลุด (Churn Risk)</span>
<AlertTriangle className="w-4 h-4 text-rose-400" />
</div>
<div className="mt-2 text-xl font-bold text-rose-400 font-mono">
{fmtNum(summary.churnRiskCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-rose-400/80 mt-0.5">เกินรอบเฉลี่ยไปมาก (Win-back)</p>
</div>
</div>
{/* ── Filters & Search Bar ───────────────────────────────────── */}
<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">
{/* Status Filter Tabs */}
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
<button
onClick={() => {
setChurnStatus('all');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
churnStatus === 'all'
? 'bg-indigo-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
สถานะทั้งหมด
</button>
<button
onClick={() => {
setChurnStatus('due_soon');
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
churnStatus === 'due_soon'
? 'bg-amber-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
ถึงรอบซื้อ (Due Soon)
</button>
<button
onClick={() => {
setChurnStatus('healthy');
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
churnStatus === 'healthy'
? 'bg-emerald-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
ปกติ (Active)
</button>
<button
onClick={() => {
setChurnStatus('churn_risk');
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
churnStatus === 'churn_risk'
? 'bg-rose-600 text-white shadow-md'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
เสี่ยงหลุด (Churn Risk)
</button>
</div>
{/* Channel Selection */}
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
<button
onClick={() => {
setChannel('all');
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'all' ? 'bg-white/15 text-white' : 'text-slate-400 hover:text-white'
}`}
>
ทุกช่องทาง
</button>
<button
onClick={() => {
setChannel('tiktok');
setPage(1);
}}
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'tiktok' ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
TikTok
</button>
<button
onClick={() => {
setChannel('shopee');
setPage(1);
}}
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'shopee' ? 'bg-orange-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Shopee
</button>
<button
onClick={() => {
setChannel('lazada');
setPage(1);
}}
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === 'lazada' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Lazada
</button>
</div>
{/* Search */}
<div className="relative min-w-[220px] flex-1 max-w-sm">
<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="ค้นหาชื่อลูกค้า, 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"
/>
</div>
</div>
{/* ── Customer Journey Swimlane Cards ────────────────────────── */}
<div className="space-y-4">
{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>
) : !data?.customers || data.customers.length === 0 ? (
<div className="py-16 text-center text-slate-500 bg-slate-900/50 rounded-2xl border border-white/10">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
</div>
) : (
data.customers.map((cust: any) => (
<div
key={cust.id}
className="bg-slate-900/60 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md hover:border-indigo-500/30 transition-all space-y-4"
>
{/* 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">
{cust.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base text-white">
{cust.displayName}
</span>
{getChannelBadge(cust.primaryChannel)}
{getStatusBadge(cust.churnStatus)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
{cust.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{cust.location}
</span>
)}
<span>•</span>
<span className="text-emerald-400 font-semibold font-mono">
ยอดสะสม {fmtBaht(cust.totalSpent)}
</span>
<span>•</span>
<span className="font-bold text-indigo-300 font-mono">
{cust.orderCount} คำสั่งซื้อ
</span>
</div>
</div>
</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="text-right">
<span className="text-[11px] text-slate-500 block">รอบซื้อเฉลี่ย</span>
<span className="font-bold font-mono text-indigo-400">
{cust.avgIntervalDays} วัน
</span>
</div>
<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-slate-300">
{cust.daysSinceLastOrder} วันที่แล้ว
</span>
</div>
<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>
</div>
</div>
{/* Visual Horizontal Timeline Bar (Swimlane) */}
<div className="space-y-2 pt-1">
<div className="text-[11px] text-slate-400 flex items-center justify-between">
<span>ไทม์ไลน์คำสั่งซื้อ (Order Timeline)</span>
<span className="text-slate-500 font-mono">
{cust.firstOrderDate.slice(0, 10)} ───► {cust.lastOrderDate.slice(0, 10)}
</span>
</div>
{/* Horizontal Bar with Nodes */}
<div className="relative w-full py-4 px-3 bg-slate-950/50 rounded-xl border border-white/5 overflow-x-auto">
<div className="relative flex items-center justify-between min-w-[500px]">
{/* Connecting line */}
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-0.5 bg-gradient-to-r from-indigo-500 via-purple-500 to-amber-500" />
{/* Order Nodes */}
{cust.orders.map((o: any, oIdx: number) => {
const isLast = oIdx === cust.orders.length - 1;
return (
<div
key={o.orderId || oIdx}
className="relative z-10 flex flex-col items-center group cursor-pointer"
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)})`}
>
{/* Node Circle */}
<div
className={`w-5 h-5 rounded-full flex items-center justify-center font-bold text-[10px] shadow-lg transition-transform group-hover:scale-125 ${
isLast
? 'bg-indigo-500 text-white ring-4 ring-indigo-500/20'
: 'bg-slate-800 text-slate-300 border border-indigo-400'
}`}
>
{oIdx + 1}
</div>
{/* Node Label Below */}
<div className="mt-1.5 text-center whitespace-nowrap">
<span className="text-[10px] text-slate-400 block font-mono">
{o.date.slice(5, 10)}
</span>
<span className="text-[10px] font-bold text-slate-200 font-mono block">
{fmtBaht(o.amount)}
</span>
</div>
</div>
);
})}
{/* 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>
</div>
</div>
</div>
{/* Card Bottom Actions */}
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-slate-500">
{cust.orders?.[0]?.productNames?.[0] ? `สินค้าล่าสุด: ${cust.orders[cust.orders.length - 1]?.productNames?.[0] || ''}` : ''}
</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"
>
<ExternalLink className="w-3.5 h-3.5" />
ดูประวัติคำสั่งซื้อทั้งหมด ({cust.orderCount} ครั้ง)
</button>
</div>
</div>
))
)}
</div>
{/* ── Pagination Bar ─────────────────────────────────────────── */}
{data?.pagination && data.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">
<div className="flex items-center gap-3">
<span>
กำลังแสดงหน้า <span className="font-semibold text-white">{page}</span> จาก{' '}
<span className="font-semibold text-white">{data.pagination.totalPages}</span> หน้า (ทั้งหมด {fmtNum(data.pagination.total)} ลูกค้า)
</span>
<div className="flex items-center gap-1.5 text-slate-500">
<span>แสดง:</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setPage(1);
}}
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-lg px-2 py-1 focus:outline-none focus:border-indigo-500"
>
<option value={20}>20 คน</option>
<option value={50}>50 คน</option>
<option value={100}>100 คน</option>
</select>
</div>
</div>
<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} / {data.pagination.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.pagination.totalPages, p + 1))}
disabled={page >= data.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>
)}
{/* ── Order Detail Modal ─────────────────────────────────────── */}
{selectedCustomer && (
<RepeatCustomerOrdersModal
customer={selectedCustomer}
onClose={() => setSelectedCustomer(null)}
/>
)}
</div>
);
}
@@ -23,7 +23,6 @@ import {
Check,
Building2,
Layers,
Sparkle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
File diff suppressed because it is too large. Load diff