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

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
# HomAroma Platform: บทวิเคราะห์โปรเจกต์ในมุมมองผู้บริหาร (Management Perspective)
> **มุมมองการวิเคราะห์:** Executive Summary, Business Strategy, ROI, Unit Economics & Digital Transformation
> **เป้าหมาย:** สรุปคุณค่าทางธุรกิจ (Business Value) สำหรับนำเสนอผู้บริหารระดับสูง (C-Level / VP / Directors)
---
## 1. Executive Summary (บทสรุปสำหรับผู้บริหาร)
ในยุคที่ต้นทุนการตลาดออนไลน์ (Customer Acquisition Cost - CAC) พุ่งสูงขึ้นอย่างต่อเนื่อง และการพึ่งพาช่องทาง Marketplace เพียงอย่างเดียวทำให้แบรนด์เผชิญกับค่าธรรมเนียมที่สูงขึ้น (Commission & Platform Fees) และการถูกปิดกั้นการเข้าถึงฐานข้อมูลลูกค้า (Data Lock-in)
โปรเจกต์ **HomAroma** ถูกวางสถาปัตยกรรมขึ้นมาเพื่อเป็น **"ระบบการค้าแบบ Omnichannel ที่ขับเคลื่อนด้วยข้อมูล (Data-Driven Commerce Ecosystem)"** โดยผสมผสานจุดแข็งของการสร้างยอดขายผ่าน Marketplace เข้ากับการสร้างช่องทาง D2C (Direct-to-Consumer) บน Web และ LINE เพื่อดึงลูกค้าเข้ามาเป็นฐานข้อมูลของตัวเอง (First-Party Data) พร้อมทั้งมีระบบ Analytics และ Automation ที่ช่วยลดต้นทุนการดำเนินงาน เพิ่มอัตราการซื้อซ้ำ (Retention Rate) และเพิ่ม Customer Lifetime Value (LTV) อย่างยั่งยืน
---
## 2. การแก้ไข Pain Points สำคัญของธุรกิจค้าปลีกออนไลน์ (Business Pain Points & Solutions)
```text
+------------------------------------+ +---------------------------------------------+
| Market Pain Points | | HomAroma Solutions |
+------------------------------------+ +---------------------------------------------+
| 1. ค่าโฆษณาแพงขึ้น ยอดซื้อครั้งเดียว | ----> | ระบบ AI Predictive Retention & CRM |
| ลูกค้าหาย (High Churn Rate) | | ทักเตือนตามรอบซื้อซ้ำ พร้อมคูปองดึงกลับเฉพาะคน |
+------------------------------------+ +---------------------------------------------+
| 2. ไม่รู้กำไรสุทธิที่แท้จริง จาก | ----> | Multi-channel Financial Reconciliation |
| ค่าธรรมเนียมแฝงในแต่ละแพลตฟอร์ม | | ตรวจสอบ Net Payout ละเอียดทุกค่าธรรมเนียม |
+------------------------------------+ +---------------------------------------------+
| 3. ยิงแอดหว่าน ไม่รู้พื้นที่ยอดขาย | ----> | Geographic Sales Intelligence (GIS Maps) |
| ทำให้สูญเสียงบประมาณการตลาด | | แผนที่เจาะลึกระดับอำเภอ/รัฐ ในไทยและ SEA |
+------------------------------------+ +---------------------------------------------+
| 4. ทำแคมเปญช้า ต้องรอโปรแกรมเมอร์ | ----> | Headless Dynamic Page Builder |
| ปรับหน้าเว็บ เสียโอกาสทางการตลาด | | ทีมการตลาดปรับเปลี่ยนหน้า Landing Page ได้ทันที |
+------------------------------------+ +---------------------------------------------+
```
---
## 3. มิติด้านกลยุทธ์ธุรกิจและผลตอบแทนการลงทุน (Strategic Pillars & Business Impact)
### 3.1 การขับเคลื่อนการเติบโตด้วยการรักษาฐานลูกค้า (Retention-Led Growth & LTV Optimization)
* **ปัญหาเดิม:** ธุรกิจ E-commerce ทั่วไปทุ่มงบ 80-90% ไปกับการหาลูกค้าใหม่ (Acquisition) ซึ่งมีต้นทุนแพงขึ้นเรื่อยๆ
* **สิ่งที่ระบบทำ:**
- สร้างอัลกอริทึมคำนวณ **Average Purchase Interval** ของลูกค้าแต่ละราย
- ทำนายวันหมดของสินค้าและแจ้งเตือนก่อนของหมด (Restock Timing)
- แบ่ง Tier ลูกค้าอัตโนมัติ: **VIP Rewards**, **Due Soon**, **Churn Risk**, และ **Nurturing**
* **ผลลัพธ์ทางธุรกิจ:**
- เปลี่ยนลูกค้าขาจรจาก Marketplace ให้กลับมาซื้อซ้ำบนช่องทางของแบรนด์ (LINE OA) ที่มีต้นทุนการสื่อสารต่ำกว่า
- เพิ่ม Customer Lifetime Value (LTV) และลดอัตราการเสียลูกค้า (Churn Rate)
### 3.2 การสร้าง First-Party Data Asset ผ่าน LINE OA (Owned Media & Social Commerce Strategy)
* **ปัญหาเดิม:** การขายบน Marketplace ทำให้แบรนด์ไม่เคย "เป็นเจ้าของลูกค้า" (Platform Lock-in) ข้อมูลลูกค้าถูกปิดกั้น ไม่สามารถทำการตลาดทางตรงหรือ Retargeting ได้โดยตรง อีกทั้งมีค่าคอมมิชชั่นสูง (10-18% ต่อออเดอร์)
* **สิ่งที่ระบบทำ:**
- วางระบบ **LINE OA + LIFF (LINE Front-end Framework)** ให้เป็น Hub ศูนย์กลางการสะสมฐานลูกค้า
- **Frictionless Shopping:** ลูกค้าสั่งซื้อและชำระเงินผ่าน LINE ได้ทันทีโดยไม่ต้องโหลดแอปเพิ่ม หรือกรอกรหัสผ่าน (Auto LINE Login)
- **Automated Lifecycle Flex Messages:** ส่งการ์ดข้อความแจ้งสถานะออเดอร์, ยืนยันยอดเงิน, แจ้งพัสดุ และทักเตือนรอบเติมสินค้าอัตโนมัติผ่าน LINE Chat สร้างความไว้วางใจและประสบการณ์ระดับพรีเมียม
- **Loyalty & Gamification:** มอบแต้มสะสมทุกการสั่งซื้อ ดึงดูดให้ลูกค้ากลับมาซื้อซ้ำบน LINE OA แทนที่จะกลับไปซื้อบน Marketplace
* **ผลลัพธ์ทางธุรกิจ:**
- เปลี่ยนค่าคอมมิชชั่นของ Marketplace (10-18%) มาเป็นส่วนต่างกำไรสุทธิ (Net Margin) ทันทีในการซื้อซ้ำครั้งต่อไป
- สร้างฐานข้อมูล **First-Party Customer Data** ที่แท้จริง สามารถ Broadcast และทำ Segmentation ทำการตลาดเชิงรุกได้ตลอดเวลาโดยไม่มีค่าใช้จ่ายแอดรายวัน
### 3.3 ความโปร่งใสด้านการเงินและต้นทุนสินค้า (Financial Transparency & Unit Economics)
* **ปัญหาเดิม:** ยอดขาย (Gross Merchandise Value - GMV) ดูเติบโต แต่เมื่อหักค่าคอมมิชชั่น, ค่าแอฟฟิลิเอต, ส่วนลด และค่าจัดส่ง พบว่ากำไรสุทธิแทบไม่เหลือ
* **สิ่งที่ระบบทำ:**
- **Financial Reconciliation:** ระบบดึงข้อมูล Settlement จริงจาก TikTok, Shopee, Lazada มากระทบยอดกับออเดอร์ เพื่อให้เห็นกำไรสุทธิระดับคำสั่งซื้อ
- **Bill of Materials (BOM) & COGS:** บันทึกต้นทุนวัตถุดิบและบรรจุภัณฑ์ต่อชิ้น ทำให้ทราบ Gross Margin ที่แท้จริงก่อนตั้งราคาขาย
- **TikTok Price Simulator:** เครื่องมือจำลองราคาขายล่วงหน้า เพื่อประเมินความคุ้มค่าก่อนยิงแคมเปญโปรโมชัน
* **ผลลัพธ์ทางธุรกิจ:** ป้องกันการขาดทุนจากการทำโปรโมชันที่ผิดพลาด และช่วยให้ผู้บริหารตัดสินใจเรื่อง Pricing Strategy ได้อย่างแม่นยำ
### 3.4 การตลาดเจาะจงเชิงพื้นที่เพื่อเพิ่มประสิทธิภาพงบโฆษณา (Geo-Targeted Advertising Efficiency)
* **ปัญหาเดิม:** การยิงแคมเปญโฆษณาแบบกว้างทั่วประเทศทำให้เกิด Waste Ad Spend ในพื้นที่ที่ไม่มีกำลังซื้อหรือไม่นิยมสินค้า
* **สิ่งที่ระบบทำ:**
- พัฒนาระบบ GIS Sales Heatmap แสดงความหนาแน่นของยอดขายรายจังหวัดและรายอำเภอ ทั้งในไทย, มาเลเซีย และเวียดนาม
- วิเคราะห์ความชอบของกลิ่นและประเภทสินค้าแยกตามภูมิภาค (Product-Location Mapping)
* **ผลลัพธ์ทางธุรกิจ:**
- ทีมการตลาดสามารถนำข้อมูลพิกัด (Provinces/Districts) ไปตั้งค่า Location Targeting บน Meta Ads, TikTok Ads และ Google Ads ได้ตรงจุด เพิ่ม Return on Ad Spend (ROAS)
- รองรับแผนยุทธศาสตร์การขยายตลาดสู่ระดับภูมิภาคเอเชียตะวันออกเฉียงใต้ (Cross-border Expansion)
### 3.5 ความคล่องตัวทางการตลาดและการเปิดตัวแคมเปญ (Marketing Agility & Time-to-Market)
* **ปัญหาเดิม:** เมื่อต้องการออกโปรโมชันใหม่หรือสร้างแคมเปญตามเทศกาล ต้องรอทีม IT พัฒนาและ Deploy ซึ่งใช้เวลาหลายวันหรือหลายสัปดาห์
* **สิ่งที่ระบบทำ:**
- ออกแบบ **Headless CMS Page Builder** ให้ทีมการตลาดสามารถสร้าง จัดเรียง ปรับแต่ง Banner, Countdown Timer, คอลเลกชันสินค้า และฟอร์มรับลูกค้าได้เอง
- รองรับระบบ Multi-language ในตัวเพื่อสื่อสารกับลูกค้าต่างชาติ
- นำ AI มาช่วยผ่าน **Model Context Protocol (MCP)** ทำให้สามารถสั่งงานปรับปรุงเมนู แปลภาษา และจัดโครงสร้างคอนเทนต์ด้วยคำสั่งภาษาธรรมชาติ
* **ผลลัพธ์ทางธุรกิจ:** ลดระยะเวลา Time-to-market ในการเปิดแคมเปญลงเหลือเพียงไม่กี่ชั่วโมง เพิ่มขีดความสามารถในการแข่งขันกับตลาด
---
## 4. ภาพรวมสถาปัตยกรรมต้นทุน (TCO & Operational Efficiency)
การเลือกใช้เทคโนโลยีที่ทันสมัยและน้ำหนักเบา ส่งผลเชิงบวกโดยตรงต่อต้นทุนการดำเนินงาน (Total Cost of Ownership - TCO):
| ปัจจัยด้านโครงสร้าง | เทคโนโลยีเดิมทั่วไป | สถาปัตยกรรม HomAroma | ประโยชน์เชิงการบริหาร (Management Benefit) |
| :--- | :--- | :--- | :--- |
| **Server Runtime** | Node.js (Heavy memory footprint) | **Bun + Elysia.js** | ทำงานเร็วกว่า 3-5 เท่า ใช้ Server Spec เล็กกว่า ประหยัดค่า Cloud Server |
| **Database & Schema** | Traditional ORM / Manual SQL | **Prisma 7 + PostgreSQL** | Data integrity สูง ป้องกันข้อผิดพลาดด้านออเดอร์และสต็อก |
| **SEO & Performance** | Client-Side Rendering (SPA) | **Next.js 15 (SSR / App Router)** | โหลดหน้าแรกทันใจ เพิ่มคะแนน SEO และ Google Ad Quality Score |
| **Mobile Friction** | โหลด Native App จาก App Store | **LINE LIFF App** | อัตรา Drop-off ต่ำ ลูกค้าไม่ต้องติดตั้งแอป เข้าถึงได้ทันทีผ่าน LINE |
| **Workflow Automation** | Manual Data Entry / หลายโปรแกรม | **Unified Back-office + MCP Server** | ลดแรงงานคนในการคีย์ข้อมูล เชื่อมโยงสต็อกและบัญชีในที่เดียว |
---
## 5. สรุปความคุ้มค่าและทิศทางในอนาคต (Strategic Conclusion)
HomAroma ไม่ใช่เพียงแค่ "เว็บไซต์ขายของออนไลน์" แต่เป็น **Digital Asset สำคัญขององค์กร** ที่ทำหน้าที่:
1. **เป็น Sales Channel** ที่เปิดรับรายได้ตลอด 24 ชม. ทั้งบน Web และ LINE
2. **เป็น Data Warehouse & Analytics Hub** ที่รวมข้อมูลยอดขาย ลูกค้า และกำไรจากทุกช่องทางเข้าไว้ด้วยกัน
3. **เป็น Marketing Automation Engine** ที่ทำงานเชิงรุกเพื่อรักษาลูกค้าเก่าและขยายลูกค้าใหม่
การมีผู้พัฒนาที่มีความเข้าใจทั้งในฝั่ง **Code/Architecture** และฝั่ง **Business/Marketing Mechanics** เช่นนี้ ช่วยให้องค์กรสามารถเชื่อมช่องว่างระหว่าง "ฝ่ายการตลาดที่ต้องการผลลัพธ์รวดเร็ว" กับ "ฝ่ายไอทีที่ต้องการระบบที่เสถียรและปลอดภัย" ได้อย่างไร้รอยต่อ
+161
View File
@@ -0,0 +1,161 @@
# ภาพรวมและสรุปรายละเอียดโปรเจกต์ HomAroma (Project Architecture & Feature Breakdown)
> **ประเภทโปรเจกต์:** Omnichannel E-commerce & Marketing Technology (MarTech) Platform
> **หมวดหมู่ธุรกิจ:** Aroma, Essential Oils & Home Fragrance
> **สถาปัตยกรรม:** Monorepo (Bun Workspaces) Full-Stack TypeScript Architecture
---
## 1. วัตถุประสงค์และภาพรวมสถาปัตยกรรม (System Architecture)
**HomAroma** เป็นระบบ E-commerce และ Marketing Technology ครบวงจรที่พัฒนาขึ้นมาเพื่อตอบโจทย์ทั้งฝั่ง **Direct-to-Consumer (D2C)**, **Social Commerce บน LINE**, **Omnichannel Marketplace Integration (TikTok, Shopee, Lazada)** และระบบหลังบ้าน **Back-office CRM & Financial Analytics**
ระบบถูกจัดโครงสร้างในรูปแบบ **Monorepo** โดยใช้ Bun เป็น Package Manager และแบ่ง Application ออกเป็น 5 ส่วนหลัก:
```mermaid
graph TD
subgraph "External Ecosystem"
DB[(PostgreSQL)]
LINE[LINE OA & LIFF Platform]
MKT[Marketplaces: TikTok / Shopee / Lazada]
AI[AI Agents / Claude / LLM]
end
subgraph "HomAroma Monorepo"
API["apps/backend<br/>(Bun + Elysia.js + Prisma 7)"]
ADMIN["apps/admin<br/>(React + Vite + D3.js)"]
WEB["apps/website<br/>(Next.js App Router SSR / SEO)"]
SPA["apps/spa<br/>(React + Vite + LINE LIFF)"]
MCP["apps/mcp-server<br/>(Model Context Protocol for AI)"]
TYPES["packages/types<br/>(Shared Data Contracts)"]
end
ADMIN -- REST API --> API
WEB -- REST API --> API
SPA -- REST API --> API
SPA -- Integration --> LINE
MCP -- Stdio / Tool Calls --> API
AI -- Agent Operations --> MCP
API -- Ingestion & Reconcile --> MKT
API -- Query / Mutate --> DB
```
---
## 2. เจาะลึกความสามารถของแต่ละแอปพลิเคชัน (Application Details)
### 2.1 Next.js Main Website (`apps/website`) — Public SEO & D2C Storefront
ทำหน้าที่เป็นหน้าร้านหลักที่เน้นการทำ **Search Engine Optimization (SEO)** และสร้าง Brand Awareness:
* **Next.js App Router & Server-Side Rendering (SSR):** โหลดรวดเร็ว รองรับ Web Vitals และ Bot ของ Search Engine ทุกประเภท
* **Internationalization (i18n):** รองรับระบบหลายภาษาผ่าน Dynamic Route `/[lang]/...` (รองรับ th, en, ja, zh, vi, ko, lo, it)
* **SEO Automation & Structured Data:**
- สร้าง Dynamic `sitemap.ts` และ `robots.ts` อัตโนมัติ
- รองรับ OpenGraph (OG), Twitter Card และ Schema.org (JSON-LD) สำหรับ Product, Breadcrumb และ Article
* **Dynamic Headless Page Builder:** ระบบเรนเดอร์เนื้อหาจาก Database (`DynamicPageRenderer.tsx`) แสดงผล Component ต่างๆ ได้อย่างยืดหยุ่น เช่น Hero Banner, Editorial Carousels, Scent Profile Breakdown, Interactive Sticky Purchase Bar, Wishlist และ FAQ Accordion
* **E-commerce Client Experience:**
- Dynamic Cart Drawer
- คูปองส่วนลดและระบบโปรโมชันอัตโนมัติ
- หน้า Checkout รองรับแนบสลิปชำระเงินและแจ้งโอน
- ระบบค้นหาและตัวกรองตามกลิ่น/คุณลักษณะ (Attribute Filters)
---
### 2.2 LINE Official Account (LINE OA) & LIFF Mobile App (`apps/spa`) — Social Commerce & Automated CRM
ระบบถูกออกแบบมาเพื่อผสานเข้ากับ **LINE Ecosystem** อย่างสมบูรณ์แบบ ทั้งในส่วนของหน้าร้านบนมือถือ (LIFF) และระบบสื่อสารอัตโนมัติผ่านแชท (LINE Messaging API):
#### 1. Seamless LINE Login & Auto-Authentication (LIFF SDK)
* **Zero-Friction Registration:** เมื่อลูกค้าคลิกลิงก์จาก LINE OA หรือ Rich Menu ระบบจะทำการ Auto-login ด้วย `lineUserId` อัตโนมัติ โดยไม่ต้องให้ลูกค้าพิมพ์ Username/Password หรือจำรหัสผ่าน
* **Customer Profile Sync:** ดึง Display Name และรูปโปรไฟล์จาก LINE เข้าสู่ฐานข้อมูลลูกค้า (`Customer`) ทันที พร้อมสร้างบัญชีสมาชิกให้โดยอัตโนมัติ
#### 2. Automated Messaging API & Rich Flex Messages (ระบบแจ้งเตือนอัตโนมัติผ่าน LINE Chat)
ระบบมีการเชื่อมต่อ **LINE Messaging API (`@line/bot-sdk`)** เพื่อยิงข้อความแบบ **Flex Message** ดีไซน์เฉพาะตัว สวยงามเหมือนการ์ดแอปพลิเคชัน แจ้งเตือนลูกค้าในทุกขั้นตอนของ Customer Journey:
* **🔔 Order Created Notification:** ส่งการ์ดแจ้งยอดชำระ สรุปรายการสินค้า และเลขที่บัญชีธนาคารทันทีหลังกดสั่งซื้อ พร้อมปุ่ม Call-to-Action "แนบสลิปชำระเงิน" และ "ติดตามสถานะออเดอร์" ที่คลิกแล้วเปิดหน้าเว็บทำรายการต่อได้ทันที
* **✨ Payment Confirmed & Points Notification:** เมื่อแอดมินหรือระบบอนุมัติสลิปโอนเงิน ระบบจะส่ง Flex Message ขอบคุณ พร้อมแจ้งแต้มสะสมที่ได้รับเพิ่มในออเดอร์นั้น (`+points`) และยอดคะแนนสะสมรวมทั้งหมด พร้อมปุ่มพาลูกค้าไปหน้า "แลกของรางวัล & คูปองสะสม"
* **🚚 Order Shipped Notification:** เมื่อพัสดุถูกจัดส่ง ระบบจะยิงข้อความแจ้งเตือนพร้อมแสดง **Tracking Number** และปุ่ม "เช็คสถานะการจัดส่ง" แบบ 1-Click
* **🚫 Order Cancelled & Auto-Refund Points:** หากคำสั่งซื้อถูกยกเลิก (เช่น เกินเวลาชำระเงิน) ระบบจะแจ้งเตือนพร้อมดำเนินการ **คืนแต้มสะสม (Refund Points)** กลับเข้ากระเป๋าลูกค้าทันทีโดยอัตโนมัติ
#### 3. Social Commerce & Customer Loyalty Experience
* **Loyalty Points Engine:** คำนวณแต้มสะสมตามยอดใช้จ่ายจริง (กำหนด Earn Rate ได้จากหลังบ้าน) ลูกค้าสามารถนำแต้มสะสมมาใช้เป็นส่วนลดเงินสดได้ในขั้นตอน Checkout
* **Coupon & Promotion Wallet:** รองรับการใช้โค้ดส่วนลด โปรโมชันตามขั้นบันได (Tiered Promotions) และโค้ดส่งฟรี
* **Customer Address Book:** บันทึกที่อยู่จัดส่งเริ่มต้น (Default Address) ของลูกค้าไว้ในระบบ ไม่ต้องพิมพ์ซ้ำเมื่อกลับมาซื้อใหม่
* **Mobile Slip Upload:** ถ่ายรูปหรือแนบสลิปการโอนเงินผ่านมือถือได้ทันที ไม่ต้องสลับหน้าจอไปส่งในแชท
#### 4. LINE Webhook Architecture & Security
* รองรับ Webhook Signature Verification (`x-line-signature` ด้วย HMAC-SHA256) เพื่อความปลอดภัยสูงสุด ป้องกันข้อความปลอมแปลง
* สถาปัตยกรรมรองรับการต่อยอดเป็น Interactive Chatbot และการ Broadcast ข้อความเฉพาะกลุ่ม (Audience-targeted Broadcast)
---
### 2.3 Admin Portal (`apps/admin`) — Back-office, MarTech & Financial Analytics
ศูนย์บัญชาการธุรกิจสำหรับผู้บริหารและทีมการตลาด รวบรวมฟังก์ชันบริหารจัดการและ Data Analytics ขั้นสูง:
#### ก. Marketing & CRM Suite
1. **Predictive Repeat Customers Dashboard & Action Plan:**
- ระบบตรวจจับรอบการซื้อซ้ำของลูกค้า (`avgIntervalDays`) และทำนายวันที่ลูกค้าจะกลับมาซื้อใหม่ (`predictedNextOrderDate`)
- แบ่งกลุ่มลูกค้าตามสถานะความเสี่ยง:
- `VIP Rewards`: ลูกค้าชั้นดี ยอดซื้อสะสมสูง
- `Due Soon (Restock)`: สินค้าใกล้หมดตามสถิติ ต้องทักเตือน
- `Overdue / Churn Risk`: ลูกค้าหลุดรอบการซื้อเฉลี่ย ต้องดึงกลับด้วย Win-back Voucher
- `Healthy / Nurturing`: อยู่ในรอบปกติ ส่งคอนเทนต์สร้างสัมพันธ์
- **One-click Personalized Copywriting:** สร้างข้อความและคูปองเฉพาะบุคคล (เช่น RESTOCK10, COMEBACK20, VIPHOMAROMA) พร้อมปุ่ม Copy สคริปต์ไปส่งแชทได้ทันที
- **CRM Pipeline Tracking:** บันทึกสถานะการติดต่อ (Pending, Contacted, Converted, Unresponsive) พร้อมประวัติ Activity Logs
2. **Geographic Sales Intelligence (Spatial Maps with D3.js):**
- แผนที่ยอดขายภูมิศาสตร์แบบ Vector SVG (Interactive Pan & Zoom, Choropleth Heatmap):
- **Thailand Sales Map:** เจาะลึกระดับ 77 จังหวัด
- **Malaysia Sales Map:** ครอบคลุม 13 รัฐ และเจาะลึก 160 เขต (Daerah)
- **Vietnam Sales Map:** ครอบคลุม 63 จังหวัด และเจาะลึก 708 อำเภอ (Quận/Huyện)
- ช่วยให้ทีมการตลาดทำ Geo-targeted Advertising (เช่น Facebook/TikTok Ads Location Targeting) ได้อย่างแม่นยำ
#### ข. Marketplace Multi-channel Financials
1. **Marketplace Ingestion & Settlement Reconciliation:**
- ดึงข้อมูล Order, SKU และ Settlement จาก **TikTok Shop, Shopee, Lazada**
- ตรวจสอบค่าธรรมเนียมจริง: Commission Fee, Affiliate Commission, Platform Growth Fee, Shipping Discounts
- คำนวณ Net Settlement (เงินเข้าบัญชีจริง) เปรียบเทียบกับ Gross Revenue
2. **TikTok Price & Profit Simulator:**
- เครื่องมือจำลองราคาขายและโปรโมชัน ช่วยคำนวณกำไรสุทธิ (Net Margin) และจุดคุ้มทุน (Break-even) ทันทีหลังหักค่าธรรมเนียมแพลตฟอร์ม
#### ค. Product & COGS Management
1. **Master Catalog & SKU Mapping:** เชื่อมโยง SKU บนแต่ละมาร์เก็ตเพลสเข้าสู่ Master Product Catalog เดียวกัน เพื่อตัดสต็อกและวิเคราะห์ยอดขายรวม
2. **Bill of Materials (BOM) & Raw Material Costing:**
- ผูกสูตรวัตถุดิบ (`RawMaterial`) เข้ากับสินค้า (`ProductVariant`)
- คำนวณต้นทุนสินค้าจริง (COGS) อัตโนมัติ เพื่อให้เห็น Gross Margin แท้จริงของแต่ละ SKU
#### ง. Headless CMS & Visual Design Tokens
1. **Dynamic Page Builder:** สร้างและแก้ไขหน้า Landing Page, จัดลำดับ Section, เพิ่มแบนเนอร์และวิดีโอแบบ Drag & Drop
2. **Theme Customization Engine:** ปรับแต่ง Color Palette, Font Family, Header Layout, Scroll Reveal Animation ได้จากหน้าบ้านโดยไม่ต้องแก้โค้ด
---
### 2.4 High-Performance Backend API (`apps/backend`)
* **Bun + Elysia.js:** เฟรมเวิร์กประสิทธิภาพสูง ประมวลผลเร็ว รองรับปริมาณ Traffic สูงด้วยการกินทรัพยากรที่ต่ำ
* **Prisma 7 ORM & PostgreSQL:** โครงสร้างข้อมูลแบบ Relational แข็งแกร่ง รองรับการทำ Indexing, Foreign Keys, Cascades และ Audit Fields
* **Modular Domain Architecture:**
- `businessAnalytics.ts` (ระบบประมวลผลสถิติและแผนที่ยอดขาย)
- `crm.ts` (ระบบจัดการลูกค้าสัมพันธ์และ Action Plan)
- `tiktokOrder.ts`, `shopee.ts`, `lazadaOrder.ts` (ประมวลผลข้อมูลมาร์เก็ตเพลส)
- `products.ts`, `orders.ts`, `coupons.ts`, `promotions.ts`, `payments.ts`
---
### 2.5 Model Context Protocol Server (`apps/mcp-server`) — AI-driven E-commerce
* **Autonomous AI Tooling:** พัฒนาระบบเชื่อมต่อกับ AI Agents ผ่านมาตรฐาน Model Context Protocol (MCP)
* ให้ AI (เช่น Claude หรือ Cursor/Antigravity) สามารถ:
- บริหารจัดการโครงสร้าง Mega Menu ข้ามภาษา
- อัปเดตข้อมูลสินค้าและแปลภาษาอัตโนมัติ (Automated Localization)
- บริหารจัดการเนื้อหาบน Page Builder ผ่าน Natural Language Command
---
## 3. สรุป Tech Stack ทั้งระบบ
| Layer | เทคโนโลยีที่เลือกใช้ |
| :--- | :--- |
| **Runtime & Package Manager** | Bun (Monorepo Workspaces) |
| **Backend Framework** | Elysia.js (TypeScript) |
| **Database & ORM** | PostgreSQL, Prisma 7 |
| **Main Website (D2C/SEO)** | Next.js 15 (App Router, SSR, Server Components), Tailwind CSS |
| **Mobile App (LINE Commerce)** | React, Vite, LINE Front-end Framework (LIFF), Tailwind CSS |
| **Admin Portal** | React, Vite, Tailwind CSS, D3.js (`d3-geo`), Lucide Icons |
| **Data Visualization & GIS** | D3-geo, SVG Vector Maps, GeoJSON (Thailand, Malaysia, Vietnam) |
| **AI Integration** | Model Context Protocol (MCP SDK, stdio transport) |
| **DevOps & Infrastructure** | Linux, Nginx, PM2, Git Post-receive Hooks (Zero-downtime Deploy) |
+94
View File
@@ -0,0 +1,94 @@
# เนื้อหาสำหรับนำไปใส่ใน Resume: สายงาน Online Marketing & E-commerce Developer
> **เป้าหมายตำแหน่งงาน:**
> • Technical Marketing Specialist / Marketing Technologist (MarTech)
> • E-commerce Growth Lead / Digital Marketing Specialist
> • Web Developer & Online Marketing Manager
---
## 1. Professional Summary (คำอธิบายโปรไฟล์สรุปสำหรับส่วนหัวของ Resume)
### ตัวเลือกที่ 1: เน้นภาพรวมผสมผสาน (MarTech & E-commerce Growth) — แนะนำ
> **EN:**
> *"Results-driven E-commerce & Marketing Technologist with deep expertise in full-stack web development and data-driven digital marketing. Proven track record of designing and scaling omnichannel commerce platforms, integrating Next.js, LINE LIFF, and Marketplace APIs (TikTok, Shopee, Lazada). Specialized in Customer Retention Analytics, Geo-targeted Advertising, Headless CMS architectures, and Marketing Automation, effectively bridging the gap between technical engineering and commercial revenue growth."*
>
> **TH:**
> *"นักการตลาดดิจิทัลและนักพัฒนาแพลตฟอร์มอีคอมเมิร์ซ (MarTech) ผู้เชี่ยวชาญการผสมผสานทักษะ Full-stack Web Development เข้ากับการทำการตลาดออนไลน์ที่ขับเคลื่อนด้วยข้อมูล มีประสบการณ์สร้างและวางระบบ Omnichannel E-commerce ครบวงจร ทั้ง Next.js SSR, LINE Social Commerce (LIFF) และระบบวิเคราะห์ข้อมูลมาร์เก็ตเพลส (TikTok, Shopee, Lazada) เชี่ยวชาญการเพิ่มยอดขายด้วย Predictive Retention Analytics, วางแผนกลยุทธ์โฆษณาเชิงพื้นที่ (GIS Geo-targeting), Headless CMS และ Marketing Automation เพื่อลดต้นทุน CAC และเพิ่ม LTV สูงสุด"*
### ตัวเลือกที่ 2: เน้นฝั่ง E-commerce Operations & Data Strategy
> **EN:**
> *"Data-oriented E-commerce Specialist with hands-on development capabilities across modern web stacks (React, Next.js, TypeScript, PostgreSQL). Experienced in architecting end-to-end multi-channel retail systems, financial settlement reconciliation, COGS/BOM margin tracking, and automated CRM retention funnels that maximize bottom-line profitability and customer retention."*
---
## 2. Core Skills & Technologies (ทักษะสำคัญสำหรับใส่ใน Resume)
| หมวดหมู่ | ทักษะและความเชี่ยวชาญ (Keywords) |
| :--- | :--- |
| **Online Marketing & Growth** | Omnichannel E-commerce Strategy, Customer Retention & LTV Optimization, Churn Prevention, Predictive Restock Cycles, Loyalty Points & Promotion Design, Conversion Rate Optimization (CRO), A/B Testing |
| **Digital Media & Advertising** | Geo-targeted Advertising (GIS Heatmap targeting), ROAS Optimization, Social Commerce Funnels, LINE OA & Broadcast Strategy, Marketplace Store Operations (TikTok Shop, Shopee, Lazada) |
| **SEO & Content Strategy** | Technical SEO, Server-Side Rendering (SSR), Core Web Vitals, Structured Data (JSON-LD), Multi-language i18n Routing, Dynamic Sitemap/Robots, Content Management Strategy |
| **MarTech & Data Analytics** | Multi-channel Settlement Reconciliation, Profit & Loss Modeling, Cost of Goods Sold (COGS/BOM), Sales Cartography/Spatial Analytics (D3.js Geo), Customer CRM Pipelines |
| **Web & App Development** | Next.js 15 (App Router), React, TypeScript, Bun, Vite, Tailwind CSS, LINE Front-end Framework (LIFF SDK), RESTful API, PostgreSQL, Prisma 7 ORM |
| **AI & Next-Gen Automation** | Model Context Protocol (MCP), LLM Agent Tooling, Autonomous Content Operations, Automated Localization & Translation Pipelines |
---
## 3. Work Experience / Featured Project (เนื้อหาสำหรับใส่ในประวัติการทำงาน)
### ตำแหน่งที่ระบุได้: **E-commerce Platform Architect & MarTech Specialist** (หรือ Full-Stack Web & Marketing Developer)
**โครงการ: HomAroma Omnichannel Commerce & MarTech Platform** *(2024 - ปัจจุบัน)*
#### สรุปความรับผิดชอบและผลงานเด่น (Action-Oriented Bullet Points ด้วยสูตร Action + Context + Outcome):
* **Omnichannel E-commerce Architecture & SEO:**
* ออกแบบและพัฒนาแพลตฟอร์ม E-commerce ขนาดใหญ่แบบ Monorepo โดยใช้ **Next.js 15 (SSR)**, **React**, **Bun** และ **PostgreSQL** รองรับผู้ใช้งานทั้งบน Desktop และ Mobile อย่างลื่นไหล
* วางโครงสร้าง **Technical SEO**, Canonical URLs และ Dynamic JSON-LD Structured Data พร้อมระบบหลายภาษา (i18n) รองรับ 8 ภาษา ส่งผลให้หน้าสินค้าและบทความติดอันดับการค้นหาบน Search Engine อย่างมีประสิทธิภาพ
* **LINE OA Ecosystem, Social Commerce & Automated Messaging API:**
* พัฒนา **Mobile Commerce Web App บน LINE Front-end Framework (LIFF SDK)** ที่ฝังตัวในแอปพลิเคชัน LINE ทำ Auto-login ด้วย LINE User ID แบบ 1-Click ปราศจากขั้นตอนลงทะเบียนที่ซับซ้อน เพิ่มอัตรา Conversion จาก LINE Chat สู่การปิดการขาย
* บูรณาการ **LINE Messaging API (`@line/bot-sdk`)** ส่งการ์ดแจ้งเตือนแบบ **Rich Flex Messages** อัตโนมัติตลอดทุกขั้นตอน (Order Created, Payment Approved, Order Shipped พร้อม Tracking Number และแจ้งเตือนยกเลิกพร้อมคืนคะแนน) สร้าง Brand Experience ที่เหนือกว่า
* ออกแบบระบบ **Loyalty Points & Coupon Wallet** บน LINE ช่วยกระตุ้นการซื้อซ้ำ (Repeat Purchase) และดึงดูดลูกค้าจาก Marketplace ให้เปลี่ยนมาซื้อบน LINE OA เพื่อลดต้นทุนค่าคอมมิชชั่นแพลตฟอร์ม 10-18%
* วางระบบอัปโหลดสลิปธนาคารผ่านมือถือแบบ Direct Slip Upload ตรวจสอบและอนุมัติผ่านหลังบ้าน พร้อมระบบจัดการที่อยู่จัดส่ง (Default Address Book) ช่วยให้การสั่งซื้อครั้งต่อไปเสร็จสิ้นภายใน 30 วินาที
* **Predictive Customer Retention & Data-Driven CRM:**
* ออกแบบระบบ **Predictive Repeat Customer Analytics** คำนวณรอบการซื้อสินค้าซ้ำเฉลี่ย (`avgIntervalDays`) และทำนายวันที่สินค้าใกล้หมด (`predictedNextOrderDate`) เพื่อส่งข้อความทักเตือนล่วงหน้า
* พัฒนา **Action Plan & CRM Pipeline** แบ่งกลุ่มลูกค้าอัตโนมัติเป็น 4 ระดับ (VIP, Due Restock, Churn Risk, Nurturing) พร้อมสร้าง Personalized Promo Code และสคริปต์ข้อความ ช่วยลดอัตราการเสียลูกค้า (Customer Churn) และเพิ่ม LTV อย่างมีนัยสำคัญ
* **Geographic Sales Intelligence & Ad Efficiency:**
* พัฒนาระบบแผนที่ยอดขายภูมิศาสตร์เชิงโต้ตอบ (**GIS Interactive Vector Maps**) ด้วย SVG และ D3.js รองรับการวิเคราะห์ข้อมูลยอดขายระดับรัฐและอำเภอ ทั้งในประเทศไทย, มาเลเซีย (160 Daerah) และเวียดนาม (708 อำเภอ)
* นำข้อมูล Heatmap ความหนาแน่นของยอดขายไปใช้กำหนด **Location Targeting** บน Meta Ads และ TikTok Ads ทำให้ลดงบโฆษณาที่สูญเปล่า (Ad Waste) และเพิ่มค่า ROAS ได้อย่างแม่นยำ
* **Multi-Channel Marketplace Financial Reconciliation & Unit Economics:**
* พัฒนาระบบรวบรวมและกระทบยอดข้อมูลคำสั่งซื้อและบัญชีรายรับ (**Financial Settlement Ingestion**) จาก TikTok Shop, Shopee และ Lazada เข้าสู่ฐานข้อมูลกลาง
* ตรวจสอบและวิเคราะห์ค่าธรรมเนียมจริง (Platform Fees, Commission, Affiliate Partner Fees, Shipping Subsidies) เทียบกับต้นทุนการผลิตจริง (COGS/BOM) เพื่อให้ผู้บริหารเห็นกำไรสุทธิ (Net Margin) ที่แท้จริงระดับคำสั่งซื้อและ SKU
* สร้างเครื่องมือ **TikTok Price Simulator** ให้ทีมการตลาดสามารถจำลองราคาขายและโปรโมชันเพื่อคำนวณจุดคุ้มทุน (Break-even) ก่อนเปิดแคมเปญ
* **Headless CMS & AI-Driven Content Operations:**
* ออกแบบ **Dynamic Page Builder** สำหรับทีมการตลาด ให้สามารถปรับแต่งหน้า Landing Page, Hero Banner และ Product Showcase ได้แบบ Drag & Drop โดยไม่ต้องเขียนโค้ดซ้ำซ้อน ลดระยะเวลา Time-to-Market ของแคมเปญใหม่
* บูรณาการระบบ **Model Context Protocol (MCP)** เพื่อเปิดทางให้ AI Agents (LLM) สามารถเข้ามาช่วยจัดการโครงสร้างเมนู แปลภาษา และปรับปรุงข้อมูลสินค้าอัตโนมัติ
---
## 4. Talking Points สำหรับการสัมภาษณ์งาน (Interview Cheatsheet)
เมื่อผู้สัมภาษณ์ถามคำถามสำคัญ ให้ใช้แนวทางการตอบดังนี้:
### Q1: "คุณมองว่าการมีความรู้ทั้ง Web Development และ Online Marketing มีประโยชน์ต่อองค์กรอย่างไร?"
> **แนวทางการตอบ:**
> *"การมีทั้งสองทักษะช่วยให้องค์กรประหยัดเวลาและงบประมาณอย่างมหาศาลครับ ในทีมการตลาดทั่วไป มักเกิดคอขวดเวลาที่ต้องการสร้าง Landing page ใหม่, ติดตั้ง Tracking Pixel หรือทำ A/B Testing เพราะต้องรอคิวจากทีม Developer แต่ด้วยความที่ผมเข้าใจทั้ง Code และ Business Funnel ผมสามารถออกแบบระบบ Headless CMS ที่ทำให้ทีมการตลาดปรับแต่งหน้าเว็บได้เองทันที และยังสามารถเชื่อมโยงข้อมูลหลังบ้าน (First-party data) จาก LINE และ Marketplace เข้าสู่ระบบ CRM ทำให้เราทำการตลาดแบบ Data-driven ได้จริง โดยไม่ต้องพึ่งพา 3rd-party tools ที่มีค่าใช้จ่ายรายเดือนสูง"*
### Q2: "โปรเจกต์นี้ช่วยแก้ปัญหาเรื่องค่าโฆษณาออนไลน์ (CAC) ที่แพงขึ้นได้อย่างไร?"
> **แนวทางการตอบ:**
> *"ผมแก้ปัญหานี้ผ่าน 2 แกนหลักครับ:
> 1. **Retention-led Growth:** แทนที่จะเอาเงินไปยิงแอดหาลูกค้าใหม่ตลอดเวลา ผมพัฒนาระบบคำนวณรอบการซื้อซ้ำ (Repeat Customer Prediction) ที่รู้ว่าน้ำหอมหรือน้ำมันหอมระเหยของลูกค้าจะหมดเมื่อไหร่ แล้วมีระบบ CRM แนะนำสคริปต์พร้อมโค้ดส่วนลดให้แอดมินทักไปเตือน ซึ่งมี Conversion Rate สูงกว่าและต้นทุนต่ำกว่าการยิงแอดใหม่มาก
> 2. **Precision Geo-targeting:** ผมสร้างแผนที่ยอดขายระดับอำเภอในไทยและประเทศเพื่อนบ้าน ทำให้เราเห็นชัดเจนว่าพื้นที่ไหนมีคำสั่งซื้อหนาแน่นและชอบสินค้าประเภทไหน เราจึงนำพิกัดเหล่านั้นไปเจาะจง Location ในการยิงแอด ลดการเสียเงินยิงแอดหว่านในพื้นที่ที่ไม่มี Conversion"*
### Q3: "ในแง่ของ E-commerce Metrics อะไรคือสิ่งที่คุณให้ความสำคัญที่สุดในระบบนี้?"
> **แนวทางการตอบ:**
> *"ผมให้ความสำคัญกับ **Net Profitability (กำไรสุทธิที่แท้จริง)** และ **Customer Lifetime Value (LTV)** ครับ ในระบบนี้ผมจึงไม่ได้ดูแค่ยอดขาย GMV รวม แต่สร้างระบบกระทบยอด Settlement ข้าม TikTok, Shopee, Lazada ที่หักค่า Commission, Affiliate fee และผูกกับสูตรวัตถุดิบ (BOM) เพื่อคำนวณ COGS ทำให้เรารู้ Margin ที่แท้จริงของแต่ละโปรโมชัน ป้องกันการยิงแคมเปญที่ยอดขายดูดีแต่แท้จริงแล้วขาดทุนครับ"*
### Q4: "ทำไมถึงเลือกใช้ LINE OA และ LIFF App ในการทำ Social Commerce และมีจุดเด่นอย่างไร?"
> **แนวทางการตอบ:**
> *"ในตลาดไทย LINE คือแอปพลิเคชันที่ลูกค้าทุกคนใช้งานทุกวันอยู่แล้วครับ การใช้ LINE LIFF ทำให้ลูกค้าไม่ต้องดาวน์โหลดแอปใหม่ ไม่ต้องจำรหัสผ่าน เพราะระบบทำ Auto-login ด้วย LINE User ID ทันที ลดขั้นตอนและแรงต้านในการสั่งซื้อ (Frictionless Buying Journey) นอกจากนี้เรายังใช้ LINE Messaging API ยิงการ์ด Flex Message แจ้งเตือนสถานะออเดอร์, แต้มสะสม และเลขพัสดุแบบเรียลไทม์ ทำให้แบรนด์สามารถเปลี่ยนผู้ซื้อขาจรจาก Marketplace ให้กลายมาเป็นสมาชิกประจำบน LINE OA ของเรา ประหยัดค่าคอมมิชชั่นแพลตฟอร์ม 10-18% ในการสั่งซื้อรอบถัดไปได้อย่างยั่งยืนครับ"*
+9
View File
@@ -63,7 +63,11 @@ import VietnamSalesMapPage from './pages/analytics/VietnamSalesMapPage'
import RepeatCustomersPage from './pages/analytics/RepeatCustomersPage'
import RepeatCustomersCalendarPage from './pages/analytics/RepeatCustomersCalendarPage'
import RepeatCustomersTimelinePage from './pages/analytics/RepeatCustomersTimelinePage'
import RepeatCustomersActionPlanPage from './pages/analytics/RepeatCustomersActionPlanPage'
import RepeatCustomerDetailPage from './pages/analytics/RepeatCustomerDetailPage'
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
function App() {
return (
@@ -133,6 +137,11 @@ function App() {
<Route path="/business-analytics/repeat-customers" element={<RepeatCustomersPage />} />
<Route path="/business-analytics/repeat-customers-calendar" element={<RepeatCustomersCalendarPage />} />
<Route path="/business-analytics/repeat-customers-timeline" element={<RepeatCustomersTimelinePage />} />
<Route path="/business-analytics/repeat-customers-action-plan" element={<RepeatCustomersActionPlanPage />} />
<Route path="/business-analytics/repeat-customer-detail" element={<RepeatCustomerDetailPage />} />
{/* Marketplace CRM */}
<Route path="/crm/marketplace-customers" element={<MarketplaceCrmPage />} />
<Route path="/crm/marketplace-customers/:id" element={<MarketplaceCustomerDetailPage />} />
{/* Tools */}
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
+7 -6
View File
@@ -38,6 +38,7 @@ import {
Map,
Calendar,
Clock,
Target,
} from 'lucide-react'
interface SidebarProps {
@@ -52,14 +53,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics')
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
const isInTools = location.pathname.startsWith('/tools')
const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis')
const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy
const isInShopee = location.pathname.startsWith('/shopee')
const isInLazada = location.pathname.startsWith('/lazada')
const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p))
const isInCustomer = ['/customers', '/subscribers', '/contacts', '/crm'].some(p => location.pathname.startsWith(p)) || isRepeatCustomers
const isInMenuManager = location.pathname.startsWith('/menu-manager')
const isInBackup = location.pathname.startsWith('/backup')
const isInSettings = location.pathname.startsWith('/settings')
@@ -101,9 +103,6 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{ icon: Package, label: t('nav.productSalesMap', 'Product Sales Map'), to: '/business-analytics/sales-map-products' },
{ icon: Map, label: t('nav.malaysiaSalesMap', 'Malaysia Map'), to: '/business-analytics/malaysia-map' },
{ icon: Map, label: t('nav.vietnamSalesMap', 'Vietnam Map'), to: '/business-analytics/vietnam-map' },
{ icon: UserCheck, label: t('nav.repeatCustomers', 'Repeat Customers'), to: '/business-analytics/repeat-customers' },
{ icon: Calendar, label: t('nav.repeatCustomersCalendar', 'Repeat Customers (Calendar)'), to: '/business-analytics/repeat-customers-calendar' },
{ icon: Clock, label: t('nav.repeatCustomersTimeline', 'Repeat Customers (Timeline)'), to: '/business-analytics/repeat-customers-timeline' },
]
const TOOL_MENU = [
@@ -146,7 +145,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
]
const CUSTOMER_MENU = [
{ icon: Users, label: t('nav.customerList', 'Customers'), to: '/customers' },
{ icon: Users, label: t('nav.marketplaceCrm', 'Marketplace CRM'), to: '/crm/marketplace-customers' },
{ icon: Target, label: t('nav.repeatCustomersActionPlan', 'Repeat Customers (CRM & Action)'), to: '/business-analytics/repeat-customers-action-plan' },
{ icon: UserCheck, label: t('nav.customerList', 'Web & LINE Customers'), to: '/customers' },
{ icon: Users, label: t('nav.newsletterSubscribers', 'Subscribers'), to: '/subscribers' },
{ icon: MessageSquare, label: t('nav.contactMessages', 'Contact Messages'), to: '/contacts' },
]
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag } from 'lucide-react';
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag, ExternalLink } from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht } from '../../utils/thaiGeoUtils';
@@ -164,9 +164,22 @@ export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatC
<span className="w-6 h-6 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-xs font-mono text-slate-300 font-medium">
#{orders.length - idx}
</span>
<span className="font-mono text-sm font-semibold text-white">
{order.orderId}
</span>
{order.channel === 'tiktok' || customer.primaryChannel === 'tiktok' ? (
<a
href={`https://seller-th.tiktok.com/order/detail?order_no=${order.orderId}&shop_region=TH`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-semibold text-rose-400 hover:text-rose-300 hover:underline inline-flex items-center gap-1 group transition-colors"
title="เปิดดูออเดอร์ใน TikTok Seller Center (แท็บใหม่)"
>
<span>{order.orderId}</span>
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover:opacity-100 transition-opacity" />
</a>
) : (
<span className="font-mono text-sm font-semibold text-white">
{order.orderId}
</span>
)}
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/5">
{order.shop}
</span>
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { Table2, Calendar, Clock } from 'lucide-react';
import { Table2, Calendar, Clock, Target, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function RepeatCustomersNavTabs() {
@@ -24,6 +24,18 @@ export default function RepeatCustomersNavTabs() {
icon: Clock,
badge: 'Predictive',
},
{
to: '/business-analytics/repeat-customers-action-plan',
label: t('repeatCustomers.tabsActionPlan', 'แผนเสนอโปรโมชัน (Action Plan)'),
icon: Target,
badge: 'Action Plan',
},
{
to: '/crm/marketplace-customers',
label: t('repeatCustomers.tabsCrm', 'จัดการลูกค้า CRM (Tracking)'),
icon: Users,
badge: 'Live CRM',
},
];
return (
@@ -0,0 +1,381 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
ShoppingBag,
Package,
Calendar,
MapPin,
RefreshCw,
Clock,
DollarSign,
Tag,
Truck,
Store,
ChevronRight,
UserCheck,
ExternalLink,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
interface OrderItem {
name: string;
variation?: string;
quantity: number;
price: number;
}
interface OrderHistory {
orderId: string;
channel: 'tiktok' | 'shopee' | 'lazada';
shop: string;
date: string;
status: string;
amount: number;
shippingFee: number;
trackingId?: string;
deliveryOption?: string;
items: OrderItem[];
}
export default function RepeatCustomerDetailPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const channel = searchParams.get('channel') || 'all';
const identifier = searchParams.get('identifier') || '';
const fallbackName = searchParams.get('name') || identifier;
const [loading, setLoading] = useState(true);
const [orders, setOrders] = useState<OrderHistory[]>([]);
const [customerDetails, setCustomerDetails] = useState<any>(null);
const fetchCustomerData = useCallback(() => {
if (!identifier) {
setLoading(false);
return;
}
setLoading(true);
const params = new URLSearchParams();
params.set('channel', channel);
params.set('identifier', identifier);
fetch(`${API_BASE}/business-analytics/repeat-customers/detail?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
setOrders(data.orders || []);
setCustomerDetails(data.customer || null);
})
.catch((err) => console.error('Failed to load customer detail page:', err))
.finally(() => setLoading(false));
}, [channel, identifier]);
useEffect(() => {
fetchCustomerData();
}, [fetchCustomerData]);
const getChannelBadge = (ch: string) => {
switch (ch) {
case 'tiktok':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
TikTok Shop
</span>
);
case 'shopee':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
Shopee
</span>
);
case 'lazada':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
Lazada
</span>
);
default:
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
{ch}
</span>
);
}
};
const getStatusBadge = (status: string) => {
const s = (status || '').toUpperCase();
if (s.includes('DELIVERED') || s.includes('COMPLETED') || s.includes('SUCCESS') || s.includes('FINISHED')) {
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/20">
{status}
</span>
);
}
if (s.includes('CANCEL') || s.includes('REFUND') || s.includes('RETURN')) {
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/20">
{status}
</span>
);
}
return (
<span className="px-2 py-0.5 rounded text-[11px] font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20">
{status || 'COMPLETED'}
</span>
);
};
const displayName = customerDetails?.displayName || fallbackName || 'ไม่ระบุชื่อ';
const totalSpent = customerDetails?.totalSpent ?? orders.reduce((sum, o) => sum + (o.amount || 0), 0);
const totalOrders = customerDetails?.totalOrders ?? orders.length;
const location = customerDetails?.location || '';
const primaryChannel = customerDetails?.channel || channel;
const shopName = customerDetails?.shop || (orders.length > 0 ? orders[0]?.shop : '');
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1400px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Navigation / Header Bar ────────────────────────────── */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-900/60 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center gap-3.5">
<button
onClick={() => navigate(-1)}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all border border-white/10 group"
title="ย้อนกลับ"
>
<ArrowLeft className="w-5 h-5 transition-transform group-hover:-translate-x-0.5" />
</button>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{displayName}
</h1>
{getChannelBadge(primaryChannel)}
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
รายละเอียดลูกค้าและประวัติคำสั่งซื้อทั้งหมด
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchCustomerData}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
รีเฟรช
</button>
</div>
</div>
{/* ── Customer Summary Profile Card ──────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Profile Card */}
<div className="md:col-span-2 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md space-y-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center font-bold text-xl text-indigo-400">
{displayName.slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold text-white">{displayName}</span>
{getChannelBadge(primaryChannel)}
</div>
<span className="text-xs text-slate-400 font-mono block mt-0.5">
ID / Username: {identifier || '-'}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-white/5 text-xs text-slate-300">
{location && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-slate-500 shrink-0" />
<span className="truncate">{location}</span>
</div>
)}
{shopName && (
<div className="flex items-center gap-2">
<Store className="w-4 h-4 text-slate-500 shrink-0" />
<span className="truncate">ร้าน: {shopName}</span>
</div>
)}
</div>
</div>
{/* Total Spent KPI */}
<div className="bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs">ยอดซื้อสะสมทั้งหมด</span>
<DollarSign className="w-4 h-4 text-emerald-400" />
</div>
<div className="my-2">
<div className="text-2xl font-bold font-mono text-emerald-400">
{fmtBaht(totalSpent)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">รวมทุกคำสั่งซื้อสำเร็จ</p>
</div>
</div>
{/* Total Orders KPI */}
<div className="bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="text-xs">จำนวนคำสั่งซื้อ</span>
<Package className="w-4 h-4 text-indigo-400" />
</div>
<div className="my-2">
<div className="text-2xl font-bold font-mono text-indigo-300">
{fmtNum(totalOrders)} <span className="text-sm font-normal text-slate-400">ครั้ง</span>
</div>
<p className="text-[11px] text-slate-500 mt-0.5">
{totalOrders >= 2 ? 'ลูกค้าซื้อซ้ำ (Repeat Customer)' : 'ลูกค้าใหม่ (First-time)'}
</p>
</div>
</div>
</div>
{/* ── Orders History List ─────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-indigo-400" />
ประวัติคำสั่งซื้อทั้งหมด ({orders.length} รายการ)
</h2>
</div>
{loading ? (
<div className="py-24 text-center text-slate-400 bg-slate-900/50 rounded-2xl border border-white/10">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังโหลดข้อมูลคำสั่งซื้อ...
</div>
) : orders.length === 0 ? (
<div className="py-16 text-center text-slate-500 bg-slate-900/50 rounded-2xl border border-white/10">
<ShoppingBag className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบประวัติคำสั่งซื้อของลูกค้ารายนี้
</div>
) : (
<div className="space-y-4">
{orders.map((o, idx) => (
<div
key={o.orderId || idx}
className="bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md hover:border-indigo-500/30 transition-all space-y-4"
>
{/* Order Header */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="flex items-center gap-3">
<span className="w-7 h-7 rounded-lg bg-slate-800 text-indigo-400 font-bold text-xs flex items-center justify-center border border-white/10 font-mono">
#{orders.length - idx}
</span>
<div>
<div className="flex items-center gap-2 flex-wrap">
{o.channel === 'tiktok' || primaryChannel === 'tiktok' ? (
<a
href={`https://seller-th.tiktok.com/order/detail?order_no=${o.orderId}&shop_region=TH`}
target="_blank"
rel="noopener noreferrer"
className="font-mono font-bold text-rose-400 hover:text-rose-300 hover:underline text-sm inline-flex items-center gap-1 group transition-colors"
title="เปิดดูออเดอร์ใน TikTok Seller Center (แท็บใหม่)"
>
<span>{o.orderId}</span>
<ExternalLink className="w-3.5 h-3.5 opacity-70 group-hover:opacity-100 transition-opacity" />
</a>
) : (
<span className="font-mono font-bold text-white text-sm">
{o.orderId}
</span>
)}
{getStatusBadge(o.status)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
<span className="flex items-center gap-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
{o.date}
</span>
{o.shop && (
<>
<span>•</span>
<span className="text-slate-400">ร้าน: {o.shop}</span>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<span className="text-[11px] text-slate-500 block">ยอดรวมออเดอร์</span>
<span className="text-base font-bold font-mono text-emerald-400">
{fmtBaht(o.amount)}
</span>
</div>
</div>
{/* Items List */}
<div className="space-y-2 bg-slate-950/40 p-3.5 rounded-xl border border-white/5">
<span className="text-[11px] font-semibold text-slate-400 block mb-2">
รายการสินค้า ({o.items?.length || 0} รายการ):
</span>
{o.items && o.items.length > 0 ? (
o.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-start justify-between gap-4 py-1.5 border-b border-white/5 last:border-0 text-xs"
>
<div className="flex-1">
<p className="text-slate-200 font-medium line-clamp-1">{item.name}</p>
{item.variation && (
<span className="text-[11px] text-slate-400 mt-0.5 block">
ตัวเลือก: {item.variation}
</span>
)}
</div>
<div className="text-right shrink-0">
<span className="text-slate-400 font-mono">
x{item.quantity} •{' '}
</span>
<span className="font-bold font-mono text-slate-200">
{fmtBaht(item.price)}
</span>
</div>
</div>
))
) : (
<p className="text-xs text-slate-500">ไม่มีข้อมูลรายละเอียดสินค้า</p>
)}
</div>
{/* Extra Order Info (Tracking / Shipping) */}
{(o.trackingId || o.deliveryOption || o.shippingFee > 0) && (
<div className="flex flex-wrap items-center justify-between text-xs text-slate-400 pt-1">
<div className="flex items-center gap-3">
{o.trackingId && (
<span className="flex items-center gap-1 font-mono text-slate-400">
<Truck className="w-3.5 h-3.5 text-slate-500" />
Tracking: {o.trackingId}
</span>
)}
{o.deliveryOption && <span>({o.deliveryOption})</span>}
</div>
{o.shippingFee > 0 && (
<span className="text-slate-500">
ค่าจัดส่ง: {fmtBaht(o.shippingFee)}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users,
UserCheck,
@@ -27,6 +28,7 @@ type SortBy = 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween';
export default function RepeatCustomersPage() {
const { t } = useTranslation();
const navigate = useNavigate();
// Filters state
const [channel, setChannel] = useState<ChannelType>('all');
@@ -127,6 +129,7 @@ export default function RepeatCustomersPage() {
'First Order Date',
'Last Order Date',
'Avg Days Between Orders',
'Predicted Next Order Date',
'Location',
'Top Purchased Products',
];
@@ -142,6 +145,7 @@ export default function RepeatCustomersPage() {
`"${c.firstOrderDate || ''}"`,
`"${c.lastOrderDate || ''}"`,
c.daysBetweenOrders || 0,
`"${c.predictedNextOrderDate || ''}"`,
`"${c.location || ''}"`,
`"${(c.topProducts || []).join(', ')}"`,
]);
@@ -200,6 +204,55 @@ export default function RepeatCustomersPage() {
}
};
const getDaysUntilPredicted = (predictedDateStr: string): number | null => {
if (!predictedDateStr) return null;
const parts = predictedDateStr.split('-');
if (parts.length !== 3) return null;
const pYear = parseInt(parts[0], 10);
const pMonth = parseInt(parts[1], 10) - 1;
const pDay = parseInt(parts[2], 10);
if (isNaN(pYear) || isNaN(pMonth) || isNaN(pDay)) return null;
const predictedDate = new Date(pYear, pMonth, pDay);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diffMs = predictedDate.getTime() - today.getTime();
return Math.round(diffMs / (1000 * 60 * 60 * 24));
};
const renderPredictedDateDiff = (predictedDateStr: string) => {
const diffDays = getDaysUntilPredicted(predictedDateStr);
if (diffDays === null) return null;
if (diffDays > 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/25 whitespace-nowrap">
เหลืออีก {diffDays} วัน
</span>
);
} else if (diffDays === 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30 animate-pulse whitespace-nowrap">
ครบกำหนดวันนี้
</span>
);
} else {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/25 whitespace-nowrap">
เกินกำหนด {Math.abs(diffDays)} วัน
</span>
);
}
};
const handleCustomerClick = (cust: any) => {
const ch = cust.primaryChannel || 'all';
const id = cust.identifier || cust.displayName || '';
const name = cust.displayName || '';
navigate(`/business-analytics/repeat-customer-detail?channel=${encodeURIComponent(ch)}&identifier=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`);
};
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Header Bar ────────────────────────────────────────── */}
@@ -733,6 +786,7 @@ export default function RepeatCustomersPage() {
<th className="py-3.5 px-4 text-right">ยอดเฉลี่ย / ออเดอร์</th>
<th className="py-3.5 px-4">ซื้อล่าสุด</th>
<th className="py-3.5 px-4 text-center">รอบซื้อซ้ำเฉลี่ย</th>
<th className="py-3.5 px-4">คาดการณ์รอบถัดไป</th>
<th className="py-3.5 px-4">สินค้าที่ซื้อบ่อย</th>
<th className="py-3.5 px-4 text-right">การจัดการ</th>
</tr>
@@ -740,14 +794,14 @@ export default function RepeatCustomersPage() {
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={10} className="py-20 text-center text-slate-400">
<td colSpan={11} className="py-20 text-center text-slate-400">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังประมวลผลข้อมูลลูกค้าซื้อซ้ำ...
</td>
</tr>
) : !data?.customers || data.customers.length === 0 ? (
<tr>
<td colSpan={10} className="py-16 text-center text-slate-500">
<td colSpan={11} className="py-16 text-center text-slate-500">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
</td>
@@ -755,6 +809,12 @@ export default function RepeatCustomersPage() {
) : (
data.customers.map((cust: any, idx: number) => {
const rowNumber = (page - 1) * limit + idx + 1;
const predictedDate = cust.predictedNextOrderDate || (
cust.lastOrderDate && cust.daysBetweenOrders > 0
? new Date(new Date(cust.lastOrderDate.replace(' ', 'T')).getTime() + cust.daysBetweenOrders * 86400000).toISOString().slice(0, 10)
: ''
);
return (
<tr
key={cust.id || idx}
@@ -766,7 +826,11 @@ export default function RepeatCustomersPage() {
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-100 text-sm">
<span
onClick={() => handleCustomerClick(cust)}
className="font-semibold text-slate-100 text-sm hover:text-indigo-300 hover:underline cursor-pointer transition-colors"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName}
</span>
{cust.location && (
@@ -831,6 +895,21 @@ export default function RepeatCustomersPage() {
)}
</td>
<td className="py-3 px-4">
{predictedDate ? (
<div className="flex flex-col gap-1">
<span className="font-mono font-bold text-amber-400">
{predictedDate}
</span>
<div>
{renderPredictedDateDiff(predictedDate)}
</div>
</div>
) : (
<span className="text-slate-600">-</span>
)}
</td>
<td className="py-3 px-4 max-w-[200px]">
<div className="flex flex-wrap gap-1">
{cust.topProducts?.map((prod: string, pIdx: number) => (
@@ -847,10 +926,10 @@ export default function RepeatCustomersPage() {
<td className="py-3 px-4 text-right">
<button
onClick={() => setSelectedCustomer(cust)}
onClick={() => handleCustomerClick(cust)}
className="px-2.5 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30 hover:text-white text-xs font-medium transition-all"
>
ดูออเดอร์
ดูรายละเอียด
</button>
</td>
</tr>
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Clock,
RefreshCw,
@@ -17,13 +18,13 @@ import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type ChurnStatus = 'all' | 'due_soon' | 'healthy' | 'churn_risk';
export default function RepeatCustomersTimelinePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [channel, setChannel] = useState<ChannelType>('all');
const [churnStatus, setChurnStatus] = useState<ChurnStatus>('all');
@@ -34,7 +35,6 @@ export default function RepeatCustomersTimelinePage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<any>(null);
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
// Debounce search
useEffect(() => {
@@ -107,6 +107,55 @@ export default function RepeatCustomersTimelinePage() {
}
};
const getDaysUntilPredicted = (predictedDateStr: string): number | null => {
if (!predictedDateStr) return null;
const parts = predictedDateStr.split('-');
if (parts.length !== 3) return null;
const pYear = parseInt(parts[0], 10);
const pMonth = parseInt(parts[1], 10) - 1;
const pDay = parseInt(parts[2], 10);
if (isNaN(pYear) || isNaN(pMonth) || isNaN(pDay)) return null;
const predictedDate = new Date(pYear, pMonth, pDay);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const diffMs = predictedDate.getTime() - today.getTime();
return Math.round(diffMs / (1000 * 60 * 60 * 24));
};
const renderPredictedDateDiff = (predictedDateStr: string) => {
const diffDays = getDaysUntilPredicted(predictedDateStr);
if (diffDays === null) return null;
if (diffDays > 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">
เหลืออีก {diffDays} วัน
</span>
);
} else if (diffDays === 0) {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30 animate-pulse">
ครบกำหนดวันนี้
</span>
);
} else {
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-rose-500/15 text-rose-300 border border-rose-500/25">
เกินกำหนด {Math.abs(diffDays)} วัน
</span>
);
}
};
const handleCustomerClick = (cust: any) => {
const ch = cust.primaryChannel || 'all';
const id = cust.identifier || cust.displayName || '';
const name = cust.displayName || '';
navigate(`/business-analytics/repeat-customer-detail?channel=${encodeURIComponent(ch)}&identifier=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`);
};
const getChannelBadge = (ch: string) => {
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok Shop</span>;
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
@@ -365,12 +414,20 @@ export default function RepeatCustomersTimelinePage() {
{/* Card Top: Customer Identity & Status */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-slate-800/80 border border-white/10 flex items-center justify-center font-bold text-sm text-indigo-400">
<div
onClick={() => handleCustomerClick(cust)}
className="w-10 h-10 rounded-xl bg-slate-800/80 border border-white/10 flex items-center justify-center font-bold text-sm text-indigo-400 cursor-pointer hover:bg-indigo-600/20 hover:border-indigo-500/40 hover:scale-105 transition-all"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base text-white">
<span
onClick={() => handleCustomerClick(cust)}
className="font-bold text-base text-white hover:text-indigo-300 hover:underline cursor-pointer transition-colors"
title="คลิกเพื่อดูรายละเอียดลูกค้า"
>
{cust.displayName}
</span>
{getChannelBadge(cust.primaryChannel)}
@@ -396,7 +453,7 @@ export default function RepeatCustomersTimelinePage() {
</div>
{/* Right side: Cycle metrics */}
<div className="flex items-center gap-4 text-xs bg-slate-950/60 px-3 py-2 rounded-xl border border-white/5">
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-xs bg-slate-950/60 px-3.5 py-2 rounded-xl border border-white/5">
<div className="text-right">
<span className="text-[11px] text-slate-500 block">รอบซื้อเฉลี่ย</span>
<span className="font-bold font-mono text-indigo-400">
@@ -413,9 +470,12 @@ export default function RepeatCustomersTimelinePage() {
<div className="w-px h-6 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-500 block">คาดการณ์รอบถัดไป</span>
<span className="font-bold font-mono text-amber-400">
{cust.predictedNextOrderDate}
</span>
<div className="flex items-center gap-1.5 justify-end mt-0.5">
<span className="font-bold font-mono text-amber-400">
{cust.predictedNextOrderDate}
</span>
{renderPredictedDateDiff(cust.predictedNextOrderDate)}
</div>
</div>
</div>
</div>
@@ -450,8 +510,8 @@ export default function RepeatCustomersTimelinePage() {
{/* Order Node */}
<div
className="relative z-10 flex flex-col items-center group cursor-pointer"
onClick={() => setSelectedCustomer(cust)}
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)})`}
onClick={() => handleCustomerClick(cust)}
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)}) — คลิกเพื่อดูรายละเอียด`}
>
{/* Node Circle */}
<div
@@ -519,19 +579,41 @@ export default function RepeatCustomersTimelinePage() {
})}
{/* Predicted Next Order Marker */}
<div className="relative z-10 flex flex-col items-center">
<div className="w-5 h-5 rounded-full border-2 border-dashed border-amber-400 bg-amber-500/20 flex items-center justify-center animate-pulse">
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
</div>
<div className="mt-1.5 text-center whitespace-nowrap">
<span className="text-[10px] text-amber-300 font-medium block">
รอบถัดไป
</span>
<span className="text-[10px] font-mono text-amber-400/80 block">
~{cust.predictedNextOrderDate.slice(5, 10)}
</span>
</div>
</div>
{(() => {
const diffDays = getDaysUntilPredicted(cust.predictedNextOrderDate);
return (
<div className="relative z-10 flex flex-col items-center">
<div className="w-5 h-5 rounded-full border-2 border-dashed border-amber-400 bg-amber-500/20 flex items-center justify-center animate-pulse">
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
</div>
<div className="mt-1.5 text-center whitespace-nowrap">
<span className="text-[10px] text-amber-300 font-medium block">
รอบถัดไป
</span>
<span className="text-[10px] font-mono text-amber-400/80 block">
~{cust.predictedNextOrderDate.slice(5, 10)}
</span>
{diffDays !== null && (
<span
className={`text-[9px] font-mono font-semibold block ${
diffDays > 0
? 'text-emerald-400'
: diffDays === 0
? 'text-amber-300 animate-pulse'
: 'text-rose-400'
}`}
>
{diffDays > 0
? `(อีก ${diffDays} วัน)`
: diffDays === 0
? '(วันนี้)'
: `(เลย ${Math.abs(diffDays)} วัน)`}
</span>
)}
</div>
</div>
);
})()}
</div>
</div>
</div>
@@ -543,11 +625,11 @@ export default function RepeatCustomersTimelinePage() {
</span>
<button
onClick={() => setSelectedCustomer(cust)}
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium border border-indigo-500/30 transition-all"
onClick={() => handleCustomerClick(cust)}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium border border-indigo-500/30 transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
ดูประวัติคำสั่งซื้อทั้งหมด ({cust.orderCount} ครั้ง)
ดูรายละเอียดลูกค้าและประวัติทั้งหมด ({cust.orderCount} ครั้ง)
</button>
</div>
</div>
@@ -603,14 +685,6 @@ export default function RepeatCustomersTimelinePage() {
</div>
</div>
)}
{/* ── Order Detail Modal ─────────────────────────────────────── */}
{selectedCustomer && (
<RepeatCustomerOrdersModal
customer={selectedCustomer}
onClose={() => setSelectedCustomer(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,891 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users,
Search,
RefreshCw,
PhoneCall,
MessageSquare,
CheckCircle2,
Clock,
AlertCircle,
Tag,
Gift,
Flame,
Crown,
ChevronLeft,
ChevronRight,
ExternalLink,
Edit3,
Save,
X,
History,
TrendingUp,
DollarSign,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type CRMStatus = 'all' | 'pending' | 'contacted' | 'converted' | 'unresponsive';
type StrategyType = 'all' | 'due_restock' | 'win_back' | 'vip_reward' | 'nurture';
interface CrmCustomer {
id: number;
channel: string;
shop: string;
identifier: string;
displayName: string;
location?: string;
province?: string;
district?: string;
orderCount: number;
totalSpent: number;
avgIntervalDays: number;
firstOrderDate?: string;
lastOrderDate?: string;
predictedNextOrderDate?: string;
topProducts?: string[];
crm?: {
id: number;
status: 'pending' | 'contacted' | 'converted' | 'unresponsive';
strategyType: string;
promoCodeSent?: string;
discountOffered?: string;
lastContactedAt?: string;
nextFollowUpDate?: string;
notes?: string;
assignedTo?: string;
activities?: Array<{
id: number;
action: string;
content: string;
author?: string;
createdAt: string;
}>;
updatedAt?: string;
};
}
export default function MarketplaceCrmPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [channel, setChannel] = useState<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<CRMStatus>('all');
const [strategyFilter, setStrategyFilter] = useState<StrategyType>('all');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [loading, setLoading] = useState(true);
const [customers, setCustomers] = useState<CrmCustomer[]>([]);
const [summary, setSummary] = useState({
totalInCrm: 0,
pendingCount: 0,
contactedCount: 0,
convertedCount: 0,
unresponsiveCount: 0,
conversionRate: 0,
totalRevenueInCrm: 0,
});
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
totalCount: 0,
totalPages: 1,
});
// Edit Note Modal / Drawer State
const [editingCustomer, setEditingCustomer] = useState<CrmCustomer | null>(null);
const [editNotes, setEditNotes] = useState('');
const [editPromoCode, setEditPromoCode] = useState('');
const [editDiscount, setEditDiscount] = useState('');
const [editStatus, setEditStatus] = useState<string>('pending');
const [editFollowUpDate, setEditFollowUpDate] = useState('');
const [savingEdit, setSavingEdit] = useState(false);
// New Activity Note Modal
const [newActivityContent, setNewActivityContent] = useState('');
const [addingActivity, setAddingActivity] = useState(false);
// Debounce search
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(handler);
}, [search]);
// Fetch CRM Customers
const fetchCrmData = useCallback(() => {
setLoading(true);
const params = new URLSearchParams();
if (channel !== 'all') params.set('channel', channel);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (strategyFilter !== 'all') params.set('strategyType', strategyFilter);
if (debouncedSearch) params.set('search', debouncedSearch);
params.set('page', String(page));
params.set('limit', String(limit));
fetch(`${API_BASE}/crm/customers?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data?.customers) {
setCustomers(data.customers);
if (data.summary) setSummary(data.summary);
if (data.pagination) setPagination(data.pagination);
}
})
.catch((err) => console.error('Failed to load CRM data:', err))
.finally(() => setLoading(false));
}, [channel, statusFilter, strategyFilter, debouncedSearch, page, limit]);
useEffect(() => {
fetchCrmData();
}, [fetchCrmData]);
// Quick Status Update
const handleQuickStatusChange = async (
customerId: number,
newStatus: 'pending' | 'contacted' | 'converted' | 'unresponsive'
) => {
try {
// Optimistic update
setCustomers((prev) =>
prev.map((c) =>
c.id === customerId
? {
...c,
crm: c.crm ? { ...c.crm, status: newStatus } : undefined,
}
: c
)
);
await fetch(`${API_BASE}/crm/customers/${customerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({ status: newStatus }),
});
fetchCrmData();
} catch (e) {
console.error('Failed to update status:', e);
}
};
// Open Edit Modal
const openEditModal = (cust: CrmCustomer) => {
setEditingCustomer(cust);
setEditNotes(cust.crm?.notes || '');
setEditPromoCode(cust.crm?.promoCodeSent || '');
setEditDiscount(cust.crm?.discountOffered || '');
setEditStatus(cust.crm?.status || 'pending');
setEditFollowUpDate(
cust.crm?.nextFollowUpDate ? cust.crm.nextFollowUpDate.slice(0, 10) : ''
);
setNewActivityContent('');
};
// Save Modal Changes
const handleSaveModal = async () => {
if (!editingCustomer) return;
setSavingEdit(true);
try {
await fetch(`${API_BASE}/crm/customers/${editingCustomer.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
notes: editNotes,
promoCodeSent: editPromoCode,
discountOffered: editDiscount,
status: editStatus,
nextFollowUpDate: editFollowUpDate || null,
}),
});
setEditingCustomer(null);
fetchCrmData();
} catch (e) {
console.error('Failed to save CRM record:', e);
} finally {
setSavingEdit(false);
}
};
// Add Activity Note
const handleAddActivity = async () => {
if (!editingCustomer || !newActivityContent.trim()) return;
setAddingActivity(true);
try {
await fetch(`${API_BASE}/crm/customers/${editingCustomer.id}/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
action: 'NOTE',
content: newActivityContent.trim(),
author: 'Admin',
}),
});
setNewActivityContent('');
fetchCrmData();
// Update local activities in editing modal
if (editingCustomer.crm) {
setEditingCustomer({
...editingCustomer,
crm: {
...editingCustomer.crm,
activities: [
{
id: Date.now(),
action: 'NOTE',
content: newActivityContent.trim(),
author: 'Admin',
createdAt: new Date().toISOString(),
},
...(editingCustomer.crm.activities || []),
],
},
});
}
} catch (e) {
console.error('Failed to add activity:', e);
} finally {
setAddingActivity(false);
}
};
const getChannelBadge = (ch: string) => {
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok</span>;
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">Lazada</span>;
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'converted':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
ซื้อซ้ำสำเร็จ
</span>
);
case 'contacted':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
<MessageSquare className="w-3.5 h-3.5 text-indigo-400" />
ส่งโปรแล้ว
</span>
);
case 'unresponsive':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-slate-500/20 text-slate-300 border border-slate-500/30">
<AlertCircle className="w-3.5 h-3.5 text-slate-400" />
ไม่สนใจ/ไม่ตอบ
</span>
);
case 'pending':
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<Clock className="w-3.5 h-3.5 text-amber-400" />
รอดำเนินการ
</span>
);
}
};
const getStrategyBadge = (type: string) => {
switch (type) {
case 'due_restock':
case 'due_soon':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/25">🔥 เตือนซื้อซ้ำ</span>;
case 'win_back':
case 'overdue':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/25">⚠️ Win-Back</span>;
case 'vip_reward':
case 'vip':
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/25">👑 VIP Reward</span>;
default:
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/25">✨ Nurturing</span>;
}
};
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Header Bar ────────────────────────────────────────── */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md">
<div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
<Users className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{t('marketplaceCrm.title', 'CRM ติดตามลูกค้า Marketplace')}
</h1>
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
ฐานข้อมูลบริหารจัดการ ติดตามสถานะ และบันทึกประวัติการทักหาลูกค้าที่นำเข้ามาจาก Marketplace
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => navigate('/business-analytics/repeat-customers-action-plan')}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-purple-600/20 hover:bg-purple-600/30 border border-purple-500/30 text-xs font-semibold text-purple-300 hover:text-white transition-all"
>
<Sparkles className="w-3.5 h-3.5" />
เปิดหน้า Action Plan
</button>
<button
onClick={() => fetchCrmData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
รีเฟรช
</button>
</div>
</div>
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
<RepeatCustomersNavTabs />
{/* ── CRM Overview KPI Cards ─────────────────────────────────── */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3.5">
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400">ลูกค้าใน CRM ทั้งหมด</span>
<Users className="w-4 h-4 text-purple-400" />
</div>
<div className="mt-2 text-2xl font-bold text-white font-mono">
{fmtNum(summary.totalInCrm)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-purple-400/80 mt-0.5">ยอดซื้อสะสม {fmtBaht(summary.totalRevenueInCrm)}</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'pending' ? 'all' : 'pending'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'pending'
? 'border-amber-400 ring-2 ring-amber-400/50 bg-amber-950/25'
: 'border-amber-500/20 hover:border-amber-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-amber-300">⏳ รอดำเนินการ</span>
<Clock className="w-4 h-4 text-amber-400" />
</div>
<div className="mt-2 text-2xl font-bold text-amber-400 font-mono">
{fmtNum(summary.pendingCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-amber-300/80 mt-0.5">ยังไม่ได้ทักหรือส่งข้อเสนอ</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'contacted' ? 'all' : 'contacted'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'contacted'
? 'border-indigo-400 ring-2 ring-indigo-400/50 bg-indigo-950/25'
: 'border-indigo-500/20 hover:border-indigo-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-indigo-300">💬 ส่งโปรแล้ว</span>
<MessageSquare className="w-4 h-4 text-indigo-400" />
</div>
<div className="mt-2 text-2xl font-bold text-indigo-400 font-mono">
{fmtNum(summary.contactedCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-indigo-300/80 mt-0.5">รอฟีดแบ็ค / ออเดอร์ซื้อซ้ำ</p>
</div>
<div
onClick={() => {
setStatusFilter((curr) => (curr === 'converted' ? 'all' : 'converted'));
setPage(1);
}}
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
statusFilter === 'converted'
? 'border-emerald-400 ring-2 ring-emerald-400/50 bg-emerald-950/25'
: 'border-emerald-500/20 hover:border-emerald-500/40'
}`}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-emerald-300">✅ ซื้อซ้ำสำเร็จ</span>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2 text-2xl font-bold text-emerald-400 font-mono">
{fmtNum(summary.convertedCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
</div>
<p className="text-[11px] text-emerald-300/80 mt-0.5">ปิดการขายสำเร็จ</p>
</div>
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-sky-300">📈 Conversion Rate</span>
<TrendingUp className="w-4 h-4 text-sky-400" />
</div>
<div className="mt-2 text-2xl font-bold text-sky-400 font-mono">
{summary.conversionRate}%
</div>
<p className="text-[11px] text-sky-300/80 mt-0.5">สำเร็จเทียบกับกลุ่มที่ทักไป</p>
</div>
</div>
{/* ── Filters & Search Controls ──────────────────────────────── */}
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{/* Channel Filter */}
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
{(['all', 'tiktok', 'shopee', 'lazada'] as ChannelType[]).map((ch) => (
<button
key={ch}
onClick={() => {
setChannel(ch);
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
channel === ch
? 'bg-purple-600 text-white font-semibold'
: 'text-slate-400 hover:text-white'
}`}
>
{ch === 'all' ? 'ทุกช่องทาง' : ch.toUpperCase()}
</button>
))}
</div>
{/* Status Filter Tabs */}
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
{[
{ id: 'all', label: 'ทั้งหมด' },
{ id: 'pending', label: '⏳ รอดำเนินการ' },
{ id: 'contacted', label: '💬 ส่งโปรแล้ว' },
{ id: 'converted', label: '✅ ซื้อซ้ำสำเร็จ' },
{ id: 'unresponsive', label: '❌ ไม่สนใจ' },
].map((st) => (
<button
key={st.id}
onClick={() => {
setStatusFilter(st.id as CRMStatus);
setPage(1);
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
statusFilter === st.id
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-400 hover:text-white'
}`}
>
{st.label}
</button>
))}
</div>
</div>
{/* Search */}
<div className="relative min-w-[240px] flex-1 max-w-xs">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="ค้นหาชื่อลูกค้า, โน้ต, ID..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-950 border border-white/10 rounded-xl text-slate-200 placeholder-slate-500 focus:outline-none focus:border-purple-500 transition-all"
/>
</div>
</div>
{/* ── CRM Table ──────────────────────────────────────────────── */}
<div className="bg-slate-900/50 rounded-2xl border border-white/10 overflow-hidden backdrop-blur-md">
{loading ? (
<div className="py-24 text-center text-slate-400">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
กำลังโหลดข้อมูลลูกค้าในระบบ CRM...
</div>
) : customers.length === 0 ? (
<div className="py-20 text-center text-slate-500">
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">ยังไม่มีลูกค้าในรายการ CRM ตามเงื่อนไขนี้</p>
<p className="text-xs text-slate-500 mt-1">
คุณสามารถกดปุ่ม <span className="text-indigo-400 font-semibold">"Import เข้า CRM"</span> ได้ที่หน้า Action Plan
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950/80 text-slate-400 border-b border-white/5 uppercase text-[11px] font-semibold">
<tr>
<th className="py-3 px-4">ลูกค้า & ช่องทาง</th>
<th className="py-3 px-4">สถิติซื้อ</th>
<th className="py-3 px-4">กลยุทธ์</th>
<th className="py-3 px-4">สถานะ CRM</th>
<th className="py-3 px-4">ข้อเสนอ / โค้ดลด</th>
<th className="py-3 px-4">บันทึกโน้ต (Notes)</th>
<th className="py-3 px-4 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{customers.map((c) => {
const crm = c.crm;
const curStatus = crm?.status || 'pending';
return (
<tr key={c.id} className="hover:bg-white/[0.02] transition-colors">
{/* Customer Info */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-2.5">
<div
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="w-8 h-8 rounded-lg bg-slate-800 border border-white/10 flex items-center justify-center font-bold text-xs text-purple-400 cursor-pointer hover:scale-105 hover:bg-purple-600/20 transition-all"
title="เปิดดูรายละเอียด CRM ลูกค้าเต็มหน้า"
>
{c.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-1.5 flex-wrap">
<span
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="font-semibold text-white hover:text-purple-300 hover:underline cursor-pointer"
title="เปิดดูรายละเอียด CRM ลูกค้าเต็มหน้า"
>
{c.displayName}
</span>
{getChannelBadge(c.channel)}
</div>
<span className="text-[11px] text-slate-500 font-mono block">
{c.identifier} {c.location ? `• ${c.location}` : ''}
</span>
</div>
</div>
</td>
{/* Stats */}
<td className="py-3.5 px-4">
<div className="font-mono">
<span className="text-emerald-400 font-semibold block">{fmtBaht(c.totalSpent)}</span>
<span className="text-slate-400 text-[11px] block">
{c.orderCount} ออเดอร์ (รอบ {c.avgIntervalDays} วัน)
</span>
</div>
</td>
{/* Strategy */}
<td className="py-3.5 px-4">
{getStrategyBadge(crm?.strategyType || 'due_restock')}
</td>
{/* Quick Status Dropdown / Buttons */}
<td className="py-3.5 px-4">
<div className="flex items-center gap-1">
<select
value={curStatus}
onChange={(e) =>
handleQuickStatusChange(
c.id,
e.target.value as 'pending' | 'contacted' | 'converted' | 'unresponsive'
)
}
className={`px-2.5 py-1 text-xs rounded-xl border font-semibold focus:outline-none transition-all ${
curStatus === 'converted'
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300'
: curStatus === 'contacted'
? 'bg-indigo-950/60 border-indigo-500/40 text-indigo-300'
: curStatus === 'unresponsive'
? 'bg-slate-900 border-slate-700 text-slate-400'
: 'bg-amber-950/60 border-amber-500/40 text-amber-300'
}`}
>
<option value="pending">⏳ รอดำเนินการ</option>
<option value="contacted">💬 ส่งโปรแล้ว</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ</option>
<option value="unresponsive">❌ ไม่สนใจ</option>
</select>
</div>
</td>
{/* Promo & Discount */}
<td className="py-3.5 px-4">
{crm?.promoCodeSent ? (
<div className="space-y-0.5">
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-bold bg-amber-500/15 text-amber-300 border border-amber-500/30">
{crm.promoCodeSent}
</span>
{crm.discountOffered && (
<span className="text-[11px] text-slate-400 block mt-0.5">{crm.discountOffered}</span>
)}
</div>
) : (
<span className="text-slate-600 text-xs">-</span>
)}
</td>
{/* Notes Preview */}
<td className="py-3.5 px-4 max-w-[200px]">
<p className="truncate text-slate-300 text-xs">
{crm?.notes || <span className="text-slate-600 italic">ไม่มีบันทึก</span>}
</p>
{crm?.activities?.[0] && (
<span className="text-[10px] text-slate-500 block truncate mt-0.5">
ล่าสุด: {crm.activities[0].content}
</span>
)}
</td>
{/* Actions */}
<td className="py-3.5 px-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => openEditModal(c)}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs transition-all"
title="แก้ไขโน้ตและสถานะการติดต่อ"
>
<Edit3 className="w-3.5 h-3.5 text-indigo-400" />
<span>จัดการ</span>
</button>
<button
onClick={() => navigate(`/crm/marketplace-customers/${c.id}`)}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/5 transition-all"
title="ดูหน้า CRM ลูกค้ารายนี้แบบเต็มหน้า"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* ── Pagination Bar ─────────────────────────────────────────── */}
{pagination.totalPages > 1 && (
<div className="p-4 bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
<span>
กำลังแสดงหน้า <span className="font-semibold text-white">{pagination.page}</span> จาก{' '}
<span className="font-semibold text-white">{pagination.totalPages}</span> หน้า (ทั้งหมด{' '}
{fmtNum(pagination.totalCount)} รายการ)
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-3 py-1 rounded-lg bg-slate-950 font-mono font-semibold text-white border border-white/10">
{page} / {pagination.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
disabled={page >= pagination.totalPages || loading}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* ── Edit Note & Activity Modal ─────────────────────────────── */}
{editingCustomer && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-lg overflow-hidden shadow-2xl space-y-4">
{/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-slate-950/60">
<div>
<h3 className="text-base font-bold text-white flex items-center gap-2">
<Edit3 className="w-4 h-4 text-indigo-400" />
จัดการบันทึก CRM: {editingCustomer.displayName}
</h3>
<span className="text-xs text-slate-500 font-mono">
{editingCustomer.channel.toUpperCase()} • {editingCustomer.identifier}
</span>
</div>
<button
onClick={() => setEditingCustomer(null)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Content */}
<div className="p-4 space-y-3.5 text-xs">
{/* Status & Follow-up Date */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-slate-400 block mb-1">สถานะติดตาม:</label>
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
>
<option value="pending">⏳ รอดำเนินการ</option>
<option value="contacted">💬 ส่งโปรแล้ว</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ</option>
<option value="unresponsive">❌ ไม่สนใจ/ไม่ตอบ</option>
</select>
</div>
<div>
<label className="text-slate-400 block mb-1">วันนัดติดตามผล:</label>
<input
type="date"
value={editFollowUpDate}
onChange={(e) => setEditFollowUpDate(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Promo Code & Discount */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-slate-400 block mb-1">โค้ดส่วนลดที่ส่ง:</label>
<input
type="text"
value={editPromoCode}
onChange={(e) => setEditPromoCode(e.target.value)}
placeholder="เช่น REPEAT10"
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white font-mono uppercase focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-slate-400 block mb-1">รายละเอียดส่วนลด:</label>
<input
type="text"
value={editDiscount}
onChange={(e) => setEditDiscount(e.target.value)}
placeholder="เช่น ลด 10% หรือส่งฟรี"
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Notes */}
<div>
<label className="text-slate-400 block mb-1">บันทึกโน้ตข้อมูลลูกค้า:</label>
<textarea
rows={3}
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="เช่น ชอบกลิ่นลาเวนเดอร์ แนะนำกลิ่นเปปเปอร์มินต์เพิ่ม นัดสั่งต้นเดือนหน้า..."
className="w-full bg-slate-950 border border-white/10 rounded-xl p-2.5 text-white focus:outline-none focus:border-indigo-500 resize-none"
/>
</div>
{/* Activity Log List */}
<div className="pt-2 border-t border-white/5 space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold flex items-center gap-1">
<History className="w-3.5 h-3.5 text-indigo-400" />
ประวัติกิจกรรม (Activity Log):
</span>
</div>
{/* Add new quick activity */}
<div className="flex gap-2">
<input
type="text"
value={newActivityContent}
onChange={(e) => setNewActivityContent(e.target.value)}
placeholder="เพิ่มบันทึกการคุย/การโทรด่วน..."
className="flex-1 bg-slate-950 border border-white/10 rounded-xl px-2.5 py-1.5 text-white text-xs focus:outline-none focus:border-indigo-500"
/>
<button
onClick={handleAddActivity}
disabled={addingActivity || !newActivityContent.trim()}
className="px-3 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold disabled:opacity-50 transition-all"
>
{addingActivity ? '...' : 'เพิ่ม'}
</button>
</div>
<div className="max-h-36 overflow-y-auto space-y-1.5 pr-1">
{editingCustomer.crm?.activities && editingCustomer.crm.activities.length > 0 ? (
editingCustomer.crm.activities.map((act) => (
<div
key={act.id}
className="p-2 rounded-lg bg-slate-950/70 border border-white/5 flex items-start justify-between gap-2"
>
<div>
<p className="text-slate-200">{act.content}</p>
<span className="text-[10px] text-slate-500">โดย: {act.author || 'Admin'}</span>
</div>
<span className="text-[10px] text-slate-500 whitespace-nowrap">
{act.createdAt ? new Date(act.createdAt).toLocaleString('th-TH') : ''}
</span>
</div>
))
) : (
<span className="text-slate-500 text-xs italic block py-1">ยังไม่มีประวัติกิจกรรม</span>
)}
</div>
</div>
</div>
{/* Modal Actions */}
<div className="p-4 border-t border-white/5 bg-slate-950/60 flex items-center justify-end gap-2">
<button
onClick={() => setEditingCustomer(null)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium transition-all"
>
ยกเลิก
</button>
<button
onClick={handleSaveModal}
disabled={savingEdit}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold shadow-lg shadow-indigo-600/20 disabled:opacity-50 transition-all"
>
<Save className="w-3.5 h-3.5" />
<span>{savingEdit ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,585 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
Users,
ShoppingBag,
Clock,
Calendar,
DollarSign,
Tag,
CheckCircle2,
AlertCircle,
MessageSquare,
History,
PhoneCall,
Save,
ExternalLink,
ChevronRight,
Flame,
Crown,
Gift,
Zap,
MapPin,
RefreshCw,
Copy,
Check,
Package,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
interface OrderItem {
name: string;
variation?: string;
quantity: number;
price: number;
}
interface OrderHistory {
orderId: string;
channel: 'tiktok' | 'shopee' | 'lazada';
shop: string;
date: string;
status: string;
amount: number;
shippingFee: number;
trackingId?: string;
deliveryOption?: string;
items: OrderItem[];
}
export default function MarketplaceCustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
const [orders, setOrders] = useState<OrderHistory[]>([]);
// Form states for CRM editing
const [status, setStatus] = useState<string>('pending');
const [strategyType, setStrategyType] = useState<string>('due_restock');
const [promoCodeSent, setPromoCodeSent] = useState<string>('');
const [discountOffered, setDiscountOffered] = useState<string>('');
const [nextFollowUpDate, setNextFollowUpDate] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [savingCrm, setSavingCrm] = useState<boolean>(false);
// Activity Log
const [newActivity, setNewActivity] = useState<string>('');
const [addingActivity, setAddingActivity] = useState<boolean>(false);
const [copiedTemplate, setCopiedTemplate] = useState<boolean>(false);
const fetchCustomerDetail = useCallback(() => {
if (!id) return;
setLoading(true);
fetch(`${API_BASE}/crm/customers/${id}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((res) => res.json())
.then((data) => {
if (data?.customer) {
const c = data.customer;
setCustomer(c);
setOrders(data.orders || []);
if (c.crm) {
setStatus(c.crm.status || 'pending');
setStrategyType(c.crm.strategyType || 'due_restock');
setPromoCodeSent(c.crm.promoCodeSent || '');
setDiscountOffered(c.crm.discountOffered || '');
setNextFollowUpDate(c.crm.nextFollowUpDate ? c.crm.nextFollowUpDate.slice(0, 10) : '');
setNotes(c.crm.notes || '');
}
}
})
.catch((err) => console.error('Failed to load CRM customer detail:', err))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchCustomerDetail();
}, [fetchCustomerDetail]);
// Save CRM changes
const handleSaveCrm = async () => {
if (!id) return;
setSavingCrm(true);
try {
await fetch(`${API_BASE}/crm/customers/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
status,
strategyType,
promoCodeSent,
discountOffered,
nextFollowUpDate: nextFollowUpDate || null,
notes,
}),
});
alert('บันทึกข้อมูล CRM เรียบร้อยแล้ว!');
fetchCustomerDetail();
} catch (e) {
console.error('Failed to save CRM:', e);
alert('เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSavingCrm(false);
}
};
// Add Activity Note
const handleAddActivity = async () => {
if (!id || !newActivity.trim()) return;
setAddingActivity(true);
try {
await fetch(`${API_BASE}/crm/customers/${id}/activities`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({
action: 'NOTE',
content: newActivity.trim(),
author: 'Admin',
}),
});
setNewActivity('');
fetchCustomerDetail();
} catch (e) {
console.error('Failed to add activity:', e);
} finally {
setAddingActivity(false);
}
};
// Quick Copy Message Template
const handleCopyChatTemplate = () => {
if (!customer) return;
const cleanName = customer.displayName.replace(/\s*\(\d+\)$/, '');
const promo = promoCodeSent || 'HOMAROMA';
const lastProd = orders?.[0]?.items?.[0]?.name || 'น้ำหอมปรับอากาศ/น้ำมันหอมระเหย HomAroma';
let msg = '';
if (strategyType === 'vip' || strategyType === 'vip_reward') {
msg = `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ HomAroma เสมอมาครับ\n\nทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณ รับส่วนลด 15% หรือรับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อ ${lastProd} นะครับ 🌿\n\nแจ้งโค้ด: [${promo}] กับแอดมินได้เลยครับ 😊`;
} else if (strategyType === 'win_back' || strategyType === 'overdue') {
msg = `สวัสดีครับคุณ ${cleanName} 🌸 HomAroma คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back ลดพิเศษ 20% [${promo}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${lastProd} หรือกลิ่นใหม่ยอดฮิต 🌿 โค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`;
} else {
msg = `สวัสดีครับคุณ ${cleanName} 🌿 จาก HomAroma นะครับ\n\n${lastProd} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ [${promo}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨ สนใจเติมสินค้าทักหาเราได้เลยนะครับ!`;
}
navigator.clipboard.writeText(msg);
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2500);
};
const getChannelBadge = (ch: string) => {
switch (ch) {
case 'tiktok':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
TikTok Shop
</span>
);
case 'shopee':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
Shopee
</span>
);
case 'lazada':
return (
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
Lazada
</span>
);
default:
return null;
}
};
if (loading) {
return (
<div className="p-6 max-w-[1400px] mx-auto text-slate-100 flex flex-col items-center justify-center min-h-[500px]">
<div className="w-10 h-10 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลลูกค้า CRM...</p>
</div>
);
}
if (!customer) {
return (
<div className="p-6 max-w-[1400px] mx-auto text-slate-100">
<button
onClick={() => navigate('/crm/marketplace-customers')}
className="flex items-center gap-2 text-xs text-slate-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="w-4 h-4" />
กลับหน้ารายการ CRM
</button>
<div className="p-8 text-center bg-slate-900/50 rounded-2xl border border-white/10 text-slate-400">
<AlertCircle className="w-10 h-10 mx-auto mb-2 opacity-40" />
ไม่พบข้อมูลลูกค้ารายนี้ในระบบ CRM
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 space-y-6 max-w-[1500px] mx-auto animate-fade-in text-slate-100">
{/* ── Top Back Bar ───────────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium border border-white/10 transition-all"
>
<ArrowLeft className="w-4 h-4" />
<span>ย้อนกลับ</span>
</button>
<div className="flex items-center gap-2">
<button
onClick={fetchCustomerDetail}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-xs font-medium border border-white/10 transition-all"
>
<RefreshCw className="w-3.5 h-3.5" />
รีเฟรช
</button>
</div>
</div>
{/* ── Customer Header Banner ─────────────────────────────────── */}
<div className="p-6 rounded-2xl bg-gradient-to-r from-slate-900/90 via-indigo-950/40 to-slate-900/90 border border-white/10 backdrop-blur-md relative overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center font-bold text-2xl text-indigo-400 shadow-xl">
{customer.displayName.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
{customer.displayName}
</h1>
{getChannelBadge(customer.channel)}
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">
{customer.shop}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1.5 flex-wrap">
<span className="font-mono text-slate-300">ID: {customer.identifier}</span>
{customer.location && (
<>
<span>•</span>
<span className="flex items-center gap-1 text-slate-400">
<MapPin className="w-3.5 h-3.5 text-indigo-400" />
{customer.location}
</span>
</>
)}
</div>
</div>
</div>
{/* Quick Metrics */}
<div className="flex items-center gap-4 bg-slate-950/60 p-3 rounded-2xl border border-white/5">
<div className="text-right">
<span className="text-[11px] text-slate-400 block">ยอดซื้อรวมทั้งหมด</span>
<span className="text-lg font-bold font-mono text-emerald-400">
{fmtBaht(customer.totalSpent)}
</span>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-400 block">จำนวนออเดอร์</span>
<span className="text-lg font-bold font-mono text-indigo-400">
{customer.orderCount} ครั้ง
</span>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="text-right">
<span className="text-[11px] text-slate-400 block">รอบซื้อเฉลี่ย</span>
<span className="text-lg font-bold font-mono text-amber-400">
{customer.avgIntervalDays} วัน
</span>
</div>
</div>
</div>
</div>
{/* ── Main Two-Column Layout ─────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* ── Left 2 Columns: CRM Management Console & Activities ─── */}
<div className="lg:col-span-2 space-y-6">
{/* CRM Action & Status Form Card */}
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-5">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<Zap className="w-4 h-4 text-indigo-400" />
สถานะการติดตามและการปฏิบัติการ CRM (Live Control)
</h2>
<button
onClick={handleCopyChatTemplate}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold border transition-all ${
copiedTemplate
? 'bg-emerald-600/30 text-emerald-300 border-emerald-500/40'
: 'bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border-white/10'
}`}
>
{copiedTemplate ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
<span>คัดลอกข้อความแล้ว!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-slate-400" />
<span>Copy Template ทักแชท</span>
</>
)}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
{/* Status Select */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">สถานะ CRM ปัจจุบัน:</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className={`w-full p-2.5 rounded-xl border font-semibold focus:outline-none transition-all ${
status === 'converted'
? 'bg-emerald-950/60 border-emerald-500/40 text-emerald-300'
: status === 'contacted'
? 'bg-indigo-950/60 border-indigo-500/40 text-indigo-300'
: status === 'unresponsive'
? 'bg-slate-950 border-slate-700 text-slate-400'
: 'bg-amber-950/60 border-amber-500/40 text-amber-300'
}`}
>
<option value="pending">⏳ รอดำเนินการ (Pending)</option>
<option value="contacted">💬 ส่งโปรโมชันแล้ว (Contacted)</option>
<option value="converted">✅ ซื้อซ้ำสำเร็จ (Converted)</option>
<option value="unresponsive">❌ ลูกค้าไม่สนใจ/ไม่ตอบ (Unresponsive)</option>
</select>
</div>
{/* Strategy Type */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">กลุ่มกลยุทธ์ (Strategy):</label>
<select
value={strategyType}
onChange={(e) => setStrategyType(e.target.value)}
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white font-medium focus:outline-none focus:border-indigo-500"
>
<option value="due_restock">🔥 เตือนซื้อซ้ำรอบสถิติ (Due Restock)</option>
<option value="win_back">⚠️ ดึงลูกค้าเสี่ยงหลุดยอด (Win-Back Churn)</option>
<option value="vip_reward">👑 ดูแลลูกค้า VIP Rewards</option>
<option value="nurture">✨ สร้างสัมพันธ์/แนะนำกลิ่นใหม่ (Nurture)</option>
</select>
</div>
{/* Promo Code Sent */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">โค้ดลดที่มอบให้:</label>
<input
type="text"
value={promoCodeSent}
onChange={(e) => setPromoCodeSent(e.target.value)}
placeholder="เช่น RESTOCK10, WINBACK20"
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white font-mono uppercase focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Discount Details */}
<div>
<label className="text-slate-400 font-semibold block mb-1.5">สิทธิประโยชน์ / ส่วนลด:</label>
<input
type="text"
value={discountOffered}
onChange={(e) => setDiscountOffered(e.target.value)}
placeholder="เช่น ลด 10% หรือส่งฟรี"
className="w-full bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Follow-up Date */}
<div className="sm:col-span-2">
<label className="text-slate-400 font-semibold block mb-1.5">วันนัดติดตามผลรอบถัดไป:</label>
<input
type="date"
value={nextFollowUpDate}
onChange={(e) => setNextFollowUpDate(e.target.value)}
className="w-full max-w-xs bg-slate-950 border border-white/10 p-2.5 rounded-xl text-white focus:outline-none focus:border-indigo-500"
/>
</div>
{/* Notes */}
<div className="sm:col-span-2">
<label className="text-slate-400 font-semibold block mb-1.5">
บันทึกข้อมูลลูกค้า (Customer Notes):
</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="จดบันทึกความชอบลูกค้า เช่น ชอบแนวหอมสดชื่น แนะนำกลิ่น Eucalyptus ไป นัดสั่งต้นเดือนหน้า..."
className="w-full bg-slate-950 border border-white/10 p-3 rounded-xl text-white focus:outline-none focus:border-indigo-500 resize-none text-xs"
/>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-white/5">
<button
onClick={handleSaveCrm}
disabled={savingCrm}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs shadow-lg shadow-indigo-600/20 disabled:opacity-50 transition-all"
>
<Save className="w-4 h-4" />
<span>{savingCrm ? 'กำลังบันทึก...' : 'บันทึกข้อมูล CRM'}</span>
</button>
</div>
</div>
{/* Activity Log Card */}
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-4">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<History className="w-4 h-4 text-indigo-400" />
ไทม์ไลน์บันทึกกิจกรรม (CRM Activity Log)
</h2>
{/* Quick Add Activity Note */}
<div className="flex gap-2">
<input
type="text"
value={newActivity}
onChange={(e) => setNewActivity(e.target.value)}
placeholder="เพิ่มบันทึกกิจกรรมด่วน เช่น 'ทักแชท TikTok แล้ว ลูกค้าอ่านแล้ว', 'โทรแจ้งโปรโมชัน'..."
className="flex-1 bg-slate-950 border border-white/10 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-indigo-500"
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddActivity();
}}
/>
<button
onClick={handleAddActivity}
disabled={addingActivity || !newActivity.trim()}
className="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs disabled:opacity-50 transition-all"
>
{addingActivity ? 'กำลังเพิ่ม...' : 'เพิ่มบันทึก'}
</button>
</div>
{/* Activity Stream */}
<div className="space-y-2.5 max-h-72 overflow-y-auto pr-1">
{customer.crm?.activities && customer.crm.activities.length > 0 ? (
customer.crm.activities.map((act: any) => (
<div
key={act.id}
className="p-3 rounded-xl bg-slate-950/70 border border-white/5 flex items-start justify-between gap-3 text-xs"
>
<div>
<p className="text-slate-200">{act.content}</p>
<span className="text-[10px] text-slate-500 mt-1 block">
โดย: {act.author || 'Admin'}
</span>
</div>
<span className="text-[10px] text-slate-500 whitespace-nowrap">
{act.createdAt ? new Date(act.createdAt).toLocaleString('th-TH') : ''}
</span>
</div>
))
) : (
<div className="py-6 text-center text-slate-500 text-xs italic">
ยังไม่มีประวัติกิจกรรม สามารถพิมพ์เพิ่มได้จากช่องด้านบน
</div>
)}
</div>
</div>
</div>
{/* ── Right Column: Order History & Purchased Products ────── */}
<div className="space-y-6">
<div className="p-5 rounded-2xl bg-slate-900/60 border border-white/10 backdrop-blur-md space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<h2 className="text-sm font-bold text-white flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-purple-400" />
ประวัติการสั่งซื้อ ({orders.length} รายการ)
</h2>
</div>
{orders.length === 0 ? (
<div className="py-8 text-center text-slate-500 text-xs italic">
ไม่พบประวัติออเดอร์ในระบบ
</div>
) : (
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-1">
{orders.map((o) => {
const isTiktok = customer.channel === 'tiktok';
const externalUrl = isTiktok
? `https://seller-th.tiktok.com/order/detail?order_no=${encodeURIComponent(o.orderId)}&shop_region=TH`
: null;
return (
<div
key={o.orderId}
className="p-3.5 rounded-xl bg-slate-950/70 border border-white/5 space-y-2 text-xs"
>
<div className="flex items-center justify-between">
{externalUrl ? (
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-purple-400 hover:text-purple-300 underline font-semibold flex items-center gap-1"
title="เปิดดูออเดอร์บน TikTok Seller Center"
>
<span>#{o.orderId}</span>
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="font-mono text-slate-300 font-semibold">#{o.orderId}</span>
)}
<span className="font-mono text-emerald-400 font-bold">{fmtBaht(o.amount)}</span>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span>{o.date}</span>
<span className="px-1.5 py-0.5 rounded bg-white/5 text-slate-400 border border-white/5">
{o.status}
</span>
</div>
{/* Items */}
<div className="pt-1.5 border-t border-white/5 space-y-1">
{o.items.map((it, idx) => (
<div key={idx} className="flex items-center justify-between text-[11px]">
<span className="text-slate-300 truncate max-w-[200px]" title={it.name}>
{it.name} {it.variation ? `(${it.variation})` : ''}
</span>
<span className="text-slate-400 font-mono">x{it.quantity}</span>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomer" (
"id" SERIAL NOT NULL,
"channel" TEXT NOT NULL,
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
"identifier" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"location" TEXT,
"province" TEXT,
"district" TEXT,
"orderCount" INTEGER NOT NULL DEFAULT 0,
"totalSpent" DOUBLE PRECISION NOT NULL DEFAULT 0,
"avgIntervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
"firstOrderDate" TEXT,
"lastOrderDate" TEXT,
"predictedNextOrderDate" TEXT,
"topProducts" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomerCrm" (
"id" SERIAL NOT NULL,
"customerId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"strategyType" TEXT NOT NULL DEFAULT 'due_restock',
"promoCodeSent" TEXT,
"discountOffered" TEXT,
"lastContactedAt" TIMESTAMP(3),
"nextFollowUpDate" TIMESTAMP(3),
"notes" TEXT,
"assignedTo" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomerCrm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "CustomerCrmActivity" (
"id" SERIAL NOT NULL,
"crmId" INTEGER NOT NULL,
"action" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerCrmActivity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_idx" ON "MarketplaceCustomer"("channel");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_totalSpent_idx" ON "MarketplaceCustomer"("totalSpent");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_orderCount_idx" ON "MarketplaceCustomer"("orderCount");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_identifier_key" ON "MarketplaceCustomer"("channel", "identifier");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_customerId_key" ON "MarketplaceCustomerCrm"("customerId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_status_idx" ON "MarketplaceCustomerCrm"("status");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_strategyType_idx" ON "MarketplaceCustomerCrm"("strategyType");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_nextFollowUpDate_idx" ON "MarketplaceCustomerCrm"("nextFollowUpDate");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "CustomerCrmActivity_crmId_idx" ON "CustomerCrmActivity"("crmId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MarketplaceCustomerCrm_customerId_fkey') THEN
ALTER TABLE "MarketplaceCustomerCrm" ADD CONSTRAINT "MarketplaceCustomerCrm_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "MarketplaceCustomer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CustomerCrmActivity_crmId_fkey') THEN
ALTER TABLE "CustomerCrmActivity" ADD CONSTRAINT "CustomerCrmActivity_crmId_fkey" FOREIGN KEY ("crmId") REFERENCES "MarketplaceCustomerCrm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+72
View File
@@ -790,3 +790,75 @@ model MarketplaceProduct {
@@index([totalRevenue])
}
// ── Marketplace Customers & CRM ───────────────────────────────────────────────
model MarketplaceCustomer {
id Int @id @default(autoincrement())
channel String // "tiktok" | "shopee" | "lazada"
shop String @default("hom-aroma")
identifier String // Unique identifier per channel (username or name+postcode)
displayName String // Display name on UI
location String? // District, Province
province String?
district String?
// Aggregated buyer stats
orderCount Int @default(0)
totalSpent Float @default(0)
avgIntervalDays Float @default(0)
firstOrderDate String?
lastOrderDate String?
predictedNextOrderDate String?
topProducts Json? // Stored as JSON array: string[]
// CRM Relationship
crm MarketplaceCustomerCrm?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channel, identifier])
@@index([channel])
@@index([totalSpent])
@@index([orderCount])
}
model MarketplaceCustomerCrm {
id Int @id @default(autoincrement())
customerId Int @unique
customer MarketplaceCustomer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// Tracking status
status String @default("pending") // "pending" | "contacted" | "converted" | "unresponsive" | "do_not_disturb"
strategyType String @default("due_restock") // "due_restock" | "win_back" | "vip_reward" | "nurture"
// Campaign & contact details
promoCodeSent String? // e.g., "REPEAT10"
discountOffered String? // e.g., "ส่วนลด 10%"
lastContactedAt DateTime?
nextFollowUpDate DateTime?
notes String?
assignedTo String?
activities CustomerCrmActivity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([strategyType])
@@index([nextFollowUpDate])
}
model CustomerCrmActivity {
id Int @id @default(autoincrement())
crmId Int
crm MarketplaceCustomerCrm @relation(fields: [crmId], references: [id], onDelete: Cascade)
action String // "STATUS_CHANGE" | "SENT_PROMO" | "CALL_NOTE" | "CHAT_NOTE"
content String
author String?
createdAt DateTime @default(now())
@@index([crmId])
}
+2 -1
View File
@@ -31,6 +31,7 @@ import { productAttributesRoute } from "./routes/product-attributes";
import { backupRoute } from "./routes/backup";
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -138,7 +139,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
@@ -1872,6 +1872,12 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
.slice(0, 3)
.map(([name]) => name);
let predictedNextOrderDate = "";
if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) {
const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000;
predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
}
return {
id: c.id,
identifier: c.identifier,
@@ -1885,6 +1891,7 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
daysBetweenOrders: c.daysBetweenOrders,
predictedNextOrderDate,
location: c.location,
topProducts: sortedProds,
};
+670
View File
@@ -0,0 +1,670 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const crmRoute = new Elysia({ prefix: "/crm" })
// ── 1. Import Single / Multiple Customers ─────────────────────────────────
.post(
"/import",
async ({ body, set }) => {
try {
const { customers } = body as {
customers: Array<{
channel: string;
shop?: string;
identifier: string;
displayName: string;
location?: string;
province?: string;
district?: string;
orderCount?: number;
totalSpent?: number;
avgIntervalDays?: number;
firstOrderDate?: string;
lastOrderDate?: string;
predictedNextOrderDate?: string;
topProducts?: string[];
initialStatus?: string;
strategyType?: string;
promoCode?: string;
discountOffered?: string;
notes?: string;
}>;
};
if (!customers || !Array.isArray(customers) || customers.length === 0) {
set.status = 400;
return { error: "customers array is required" };
}
const results = [];
for (const c of customers) {
const channel = (c.channel || "tiktok").toLowerCase();
const identifier = (c.identifier || "").trim();
if (!identifier) continue;
// Upsert MarketplaceCustomer
const cust = await prisma.marketplaceCustomer.upsert({
where: {
channel_identifier: {
channel,
identifier,
},
},
update: {
displayName: c.displayName || identifier,
shop: c.shop || "hom-aroma",
location: c.location || undefined,
province: c.province || undefined,
district: c.district || undefined,
orderCount: typeof c.orderCount === "number" ? c.orderCount : undefined,
totalSpent: typeof c.totalSpent === "number" ? c.totalSpent : undefined,
avgIntervalDays: typeof c.avgIntervalDays === "number" ? c.avgIntervalDays : undefined,
firstOrderDate: c.firstOrderDate || undefined,
lastOrderDate: c.lastOrderDate || undefined,
predictedNextOrderDate: c.predictedNextOrderDate || undefined,
topProducts: c.topProducts ? (c.topProducts as any) : undefined,
},
create: {
channel,
shop: c.shop || "hom-aroma",
identifier,
displayName: c.displayName || identifier,
location: c.location || "",
province: c.province || "",
district: c.district || "",
orderCount: c.orderCount || 0,
totalSpent: c.totalSpent || 0,
avgIntervalDays: c.avgIntervalDays || 0,
firstOrderDate: c.firstOrderDate || "",
lastOrderDate: c.lastOrderDate || "",
predictedNextOrderDate: c.predictedNextOrderDate || "",
topProducts: c.topProducts ? (c.topProducts as any) : [],
},
});
// Check if CRM record exists
let crm = await prisma.marketplaceCustomerCrm.findUnique({
where: { customerId: cust.id },
});
if (!crm) {
crm = await prisma.marketplaceCustomerCrm.create({
data: {
customerId: cust.id,
status: c.initialStatus || "pending",
strategyType: c.strategyType || "due_restock",
promoCodeSent: c.promoCode || null,
discountOffered: c.discountOffered || null,
notes: c.notes || null,
activities: {
create: {
action: "IMPORT",
content: `นำเข้าลูกค้าจาก Action Plan (${channel})`,
author: "System",
},
},
},
});
}
results.push({
id: cust.id,
identifier: cust.identifier,
channel: cust.channel,
displayName: cust.displayName,
crmId: crm.id,
status: crm.status,
});
}
return {
success: true,
importedCount: results.length,
results,
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to import customers" };
}
},
{
body: t.Object({
customers: t.Array(
t.Object({
channel: t.String(),
shop: t.Optional(t.String()),
identifier: t.String(),
displayName: t.String(),
location: t.Optional(t.String()),
province: t.Optional(t.String()),
district: t.Optional(t.String()),
orderCount: t.Optional(t.Number()),
totalSpent: t.Optional(t.Number()),
avgIntervalDays: t.Optional(t.Number()),
firstOrderDate: t.Optional(t.String()),
lastOrderDate: t.Optional(t.String()),
predictedNextOrderDate: t.Optional(t.String()),
topProducts: t.Optional(t.Array(t.String())),
initialStatus: t.Optional(t.String()),
strategyType: t.Optional(t.String()),
promoCode: t.Optional(t.String()),
discountOffered: t.Optional(t.String()),
notes: t.Optional(t.String()),
})
),
}),
}
)
// ── 2. Check Which Customers are Already Imported ─────────────────────────
.post(
"/check-imported",
async ({ body, set }) => {
try {
const { keys } = body as {
keys: Array<{ channel: string; identifier: string }>;
};
if (!keys || !Array.isArray(keys) || keys.length === 0) {
return { importedMap: {} };
}
const conditions = keys.map((k) => ({
channel: k.channel.toLowerCase(),
identifier: k.identifier.trim(),
}));
const existing = await prisma.marketplaceCustomer.findMany({
where: {
OR: conditions,
},
include: {
crm: true,
},
});
// Map key format: "channel:identifier" => { customerId, crmId, status, notes, promoCodeSent }
const importedMap: Record<string, any> = {};
for (const item of existing) {
const mapKey = `${item.channel}:${item.identifier.toLowerCase()}`;
importedMap[mapKey] = {
id: item.id,
channel: item.channel,
identifier: item.identifier,
displayName: item.displayName,
crmId: item.crm?.id,
status: item.crm?.status || "pending",
notes: item.crm?.notes || "",
promoCodeSent: item.crm?.promoCodeSent || "",
discountOffered: item.crm?.discountOffered || "",
strategyType: item.crm?.strategyType || "due_restock",
updatedAt: item.crm?.updatedAt,
};
}
return { importedMap };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to check imported customers" };
}
},
{
body: t.Object({
keys: t.Array(
t.Object({
channel: t.String(),
identifier: t.String(),
})
),
}),
}
)
// ── 3. List CRM Customers with Stats, Filters, and Pagination ─────────────
.get("/customers", async ({ query, set }) => {
try {
const channel = (query.channel as string) || "all";
const status = (query.status as string) || "all";
const strategyType = (query.strategyType as string) || "all";
const search = (query.search as string) || "";
const page = Math.max(1, parseInt((query.page as string) || "1"));
const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20")));
const sortBy = (query.sortBy as string) || "updatedAt"; // 'updatedAt' | 'totalSpent' | 'orderCount' | 'nextFollowUpDate'
const sortOrder = (query.sortOrder as string) || "desc";
const where: any = {};
if (channel !== "all") {
where.channel = channel;
}
if (status !== "all") {
where.crm = { ...(where.crm || {}), status };
}
if (strategyType !== "all") {
where.crm = { ...(where.crm || {}), strategyType };
}
if (search) {
where.OR = [
{ displayName: { contains: search, mode: "insensitive" } },
{ identifier: { contains: search, mode: "insensitive" } },
{ location: { contains: search, mode: "insensitive" } },
{ crm: { notes: { contains: search, mode: "insensitive" } } },
];
}
// Total count
const totalCount = await prisma.marketplaceCustomer.count({ where });
// Build Order By
let orderBy: any = {};
if (sortBy === "totalSpent") {
orderBy = { totalSpent: sortOrder };
} else if (sortBy === "orderCount") {
orderBy = { orderCount: sortOrder };
} else if (sortBy === "nextFollowUpDate") {
orderBy = { crm: { nextFollowUpDate: sortOrder } };
} else {
orderBy = { updatedAt: sortOrder };
}
const customers = await prisma.marketplaceCustomer.findMany({
where,
include: {
crm: {
include: {
activities: {
orderBy: { createdAt: "desc" },
take: 5,
},
},
},
},
orderBy,
skip: (page - 1) * limit,
take: limit,
});
// Overall CRM summary metrics
const [
totalInCrm,
pendingCount,
contactedCount,
convertedCount,
unresponsiveCount,
aggregateStats,
] = await Promise.all([
prisma.marketplaceCustomerCrm.count(),
prisma.marketplaceCustomerCrm.count({ where: { status: "pending" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "contacted" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "converted" } }),
prisma.marketplaceCustomerCrm.count({ where: { status: "unresponsive" } }),
prisma.marketplaceCustomer.aggregate({
_sum: { totalSpent: true },
_avg: { avgIntervalDays: true },
}),
]);
const conversionRate =
contactedCount + convertedCount > 0
? Number(((convertedCount / (contactedCount + convertedCount)) * 100).toFixed(1))
: 0;
return {
summary: {
totalInCrm,
pendingCount,
contactedCount,
convertedCount,
unresponsiveCount,
conversionRate,
totalRevenueInCrm: aggregateStats._sum.totalSpent || 0,
},
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
},
customers: customers.map((c) => ({
id: c.id,
channel: c.channel,
shop: c.shop,
identifier: c.identifier,
displayName: c.displayName,
location: c.location,
province: c.province,
district: c.district,
orderCount: c.orderCount,
totalSpent: c.totalSpent,
avgIntervalDays: c.avgIntervalDays,
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
predictedNextOrderDate: c.predictedNextOrderDate,
topProducts: c.topProducts,
crm: c.crm
? {
id: c.crm.id,
status: c.crm.status,
strategyType: c.crm.strategyType,
promoCodeSent: c.crm.promoCodeSent,
discountOffered: c.crm.discountOffered,
lastContactedAt: c.crm.lastContactedAt,
nextFollowUpDate: c.crm.nextFollowUpDate,
notes: c.crm.notes,
assignedTo: c.crm.assignedTo,
activities: c.crm.activities,
updatedAt: c.crm.updatedAt,
}
: null,
})),
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to list CRM customers" };
}
})
// ── 3.1 Get Single CRM Customer Detail with Order History ─────────────────
.get("/customers/:id", async ({ params, set }) => {
try {
const id = parseInt(params.id);
const cust = await prisma.marketplaceCustomer.findUnique({
where: { id },
include: {
crm: {
include: {
activities: {
orderBy: { createdAt: "desc" },
},
},
},
},
});
if (!cust) {
set.status = 404;
return { error: "Customer not found" };
}
// Fetch order history for this customer based on channel & identifier
let orders: any[] = [];
const channel = cust.channel.toLowerCase();
const identifier = cust.identifier;
if (channel === "tiktok") {
const dbOrders = await prisma.tiktokOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { createdTime: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
date: o.createdTime,
status: o.status,
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingId,
deliveryOption: o.deliveryOption,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variation,
quantity: it.quantity,
price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0,
})),
}));
} else if (channel === "shopee") {
const dbOrders = await prisma.shopeeOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { orderCreatedAt: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
date: o.orderCreatedAt,
status: o.status,
amount: Number(o.buyerPaidAmount || o.totalAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingNo,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variantName,
quantity: it.quantity,
price: Number(it.dealPrice || it.originalPrice) || 0,
})),
}));
} else if (channel === "lazada") {
const parts = identifier.split("_");
const namePart = parts[0];
const postCodePart = parts[1] || "";
const where: any = {
customerName: { equals: namePart, mode: "insensitive" },
};
if (postCodePart) {
where.shippingPostCode = postCodePart;
}
const dbOrders = await prisma.lazadaOrder.findMany({
where,
orderBy: { createTime: "desc" },
include: {
items: true,
},
});
orders = dbOrders.map((o) => ({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
date: o.createTime,
status: o.status || "Completed",
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingCode,
items: o.items.map((it) => ({
name: it.itemName,
variation: it.variation,
quantity: 1,
price: Number(it.paidPrice || it.unitPrice) || 0,
})),
}));
}
return {
customer: cust,
orders,
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to fetch customer detail" };
}
})
// ── 4. Update CRM Status & Notes ──────────────────────────────────────────
.patch(
"/customers/:id",
async ({ params, body, set }) => {
try {
const id = parseInt(params.id);
const {
status,
strategyType,
notes,
promoCodeSent,
discountOffered,
nextFollowUpDate,
assignedTo,
activityAction,
activityContent,
author,
} = body as {
status?: string;
strategyType?: string;
notes?: string;
promoCodeSent?: string;
discountOffered?: string;
nextFollowUpDate?: string | null;
assignedTo?: string;
activityAction?: string;
activityContent?: string;
author?: string;
};
// Find customer
const cust = await prisma.marketplaceCustomer.findUnique({
where: { id },
include: { crm: true },
});
if (!cust) {
set.status = 404;
return { error: "Marketplace customer not found" };
}
const updateData: any = {};
if (status) {
updateData.status = status;
if (status === "contacted" || status === "converted") {
updateData.lastContactedAt = new Date();
}
}
if (strategyType) updateData.strategyType = strategyType;
if (typeof notes !== "undefined") updateData.notes = notes;
if (typeof promoCodeSent !== "undefined") updateData.promoCodeSent = promoCodeSent;
if (typeof discountOffered !== "undefined") updateData.discountOffered = discountOffered;
if (typeof assignedTo !== "undefined") updateData.assignedTo = assignedTo;
if (typeof nextFollowUpDate !== "undefined") {
updateData.nextFollowUpDate = nextFollowUpDate ? new Date(nextFollowUpDate) : null;
}
let crm = cust.crm;
if (!crm) {
crm = await prisma.marketplaceCustomerCrm.create({
data: {
customerId: cust.id,
status: status || "pending",
strategyType: strategyType || "due_restock",
...updateData,
},
});
} else {
crm = await prisma.marketplaceCustomerCrm.update({
where: { id: crm.id },
data: updateData,
});
}
// Add Activity Log if supplied or if status changed
if (activityAction && activityContent) {
await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: activityAction,
content: activityContent,
author: author || "Admin",
},
});
} else if (status && status !== cust.crm?.status) {
const statusLabels: Record<string, string> = {
pending: "⏳ รอดำเนินการ",
contacted: "💬 ส่งโปรแล้ว",
converted: "✅ ซื้อซ้ำสำเร็จ",
unresponsive: "❌ ลูกค้าไม่ตอบ/ไม่สนใจ",
do_not_disturb: "⛔ ขอไม่รับการติดต่อ",
};
await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: "STATUS_CHANGE",
content: `เปลี่ยนสถานะเป็น: ${statusLabels[status] || status}`,
author: author || "Admin",
},
});
}
return {
success: true,
customer: {
...cust,
crm,
},
};
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to update CRM status" };
}
},
{
body: t.Object({
status: t.Optional(t.String()),
strategyType: t.Optional(t.String()),
notes: t.Optional(t.String()),
promoCodeSent: t.Optional(t.String()),
discountOffered: t.Optional(t.String()),
nextFollowUpDate: t.Optional(t.Union([t.String(), t.Null()])),
assignedTo: t.Optional(t.String()),
activityAction: t.Optional(t.String()),
activityContent: t.Optional(t.String()),
author: t.Optional(t.String()),
}),
}
)
// ── 5. Add Custom Activity Note ───────────────────────────────────────────
.post(
"/customers/:id/activities",
async ({ params, body, set }) => {
try {
const customerId = parseInt(params.id);
const { action, content, author } = body as {
action: string;
content: string;
author?: string;
};
const crm = await prisma.marketplaceCustomerCrm.findUnique({
where: { customerId },
});
if (!crm) {
set.status = 404;
return { error: "Customer CRM record not found" };
}
const activity = await prisma.customerCrmActivity.create({
data: {
crmId: crm.id,
action: action || "NOTE",
content,
author: author || "Admin",
},
});
return { success: true, activity };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to add activity" };
}
},
{
body: t.Object({
action: t.String(),
content: t.String(),
author: t.Optional(t.String()),
}),
}
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

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