diff --git a/PROJECT_MANAGEMENT_PERSPECTIVE.md b/PROJECT_MANAGEMENT_PERSPECTIVE.md new file mode 100644 index 0000000..6054db4 --- /dev/null +++ b/PROJECT_MANAGEMENT_PERSPECTIVE.md @@ -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** เช่นนี้ ช่วยให้องค์กรสามารถเชื่อมช่องว่างระหว่าง "ฝ่ายการตลาดที่ต้องการผลลัพธ์รวดเร็ว" กับ "ฝ่ายไอทีที่ต้องการระบบที่เสถียรและปลอดภัย" ได้อย่างไร้รอยต่อ diff --git a/PROJECT_SYSTEM_SUMMARY.md b/PROJECT_SYSTEM_SUMMARY.md new file mode 100644 index 0000000..eb22d01 --- /dev/null +++ b/PROJECT_SYSTEM_SUMMARY.md @@ -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
(Bun + Elysia.js + Prisma 7)"] + ADMIN["apps/admin
(React + Vite + D3.js)"] + WEB["apps/website
(Next.js App Router SSR / SEO)"] + SPA["apps/spa
(React + Vite + LINE LIFF)"] + MCP["apps/mcp-server
(Model Context Protocol for AI)"] + TYPES["packages/types
(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) | diff --git a/RESUME_PORTFOLIO_MARKETING_DEV.md b/RESUME_PORTFOLIO_MARKETING_DEV.md new file mode 100644 index 0000000..43e0a9f --- /dev/null +++ b/RESUME_PORTFOLIO_MARKETING_DEV.md @@ -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% ในการสั่งซื้อรอบถัดไปได้อย่างยั่งยืนครับ"* diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4e3290c..61c5418 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + {/* Marketplace CRM */} + } /> + } /> {/* Tools */} } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index e9d8b2d..72ab07a 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -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' }, ] diff --git a/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx b/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx index 45f0c16..9377618 100644 --- a/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx +++ b/apps/admin/src/components/analytics/RepeatCustomerOrdersModal.tsx @@ -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 #{orders.length - idx} - - {order.orderId} - + {order.channel === 'tiktok' || customer.primaryChannel === 'tiktok' ? ( + + {order.orderId} + + + ) : ( + + {order.orderId} + + )} {order.shop} diff --git a/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx b/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx index ab749b4..4120ef3 100644 --- a/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx +++ b/apps/admin/src/components/analytics/RepeatCustomersNavTabs.tsx @@ -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 ( diff --git a/apps/admin/src/pages/analytics/RepeatCustomerDetailPage.tsx b/apps/admin/src/pages/analytics/RepeatCustomerDetailPage.tsx new file mode 100644 index 0000000..be37e5d --- /dev/null +++ b/apps/admin/src/pages/analytics/RepeatCustomerDetailPage.tsx @@ -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([]); + const [customerDetails, setCustomerDetails] = useState(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 ( + + TikTok Shop + + ); + case 'shopee': + return ( + + Shopee + + ); + case 'lazada': + return ( + + Lazada + + ); + default: + return ( + + {ch} + + ); + } + }; + + const getStatusBadge = (status: string) => { + const s = (status || '').toUpperCase(); + if (s.includes('DELIVERED') || s.includes('COMPLETED') || s.includes('SUCCESS') || s.includes('FINISHED')) { + return ( + + {status} + + ); + } + if (s.includes('CANCEL') || s.includes('REFUND') || s.includes('RETURN')) { + return ( + + {status} + + ); + } + return ( + + {status || 'COMPLETED'} + + ); + }; + + 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 ( +
+ {/* ── Top Navigation / Header Bar ────────────────────────────── */} +
+
+ +
+
+

+ {displayName} +

+ {getChannelBadge(primaryChannel)} +
+

+ รายละเอียดลูกค้าและประวัติคำสั่งซื้อทั้งหมด +

+
+
+ +
+ +
+
+ + {/* ── Customer Summary Profile Card ──────────────────────────── */} +
+ {/* Profile Card */} +
+
+
+ {displayName.slice(0, 2).toUpperCase()} +
+
+
+ {displayName} + {getChannelBadge(primaryChannel)} +
+ + ID / Username: {identifier || '-'} + +
+
+ +
+ {location && ( +
+ + {location} +
+ )} + {shopName && ( +
+ + ร้าน: {shopName} +
+ )} +
+
+ + {/* Total Spent KPI */} +
+
+ ยอดซื้อสะสมทั้งหมด + +
+
+
+ {fmtBaht(totalSpent)} +
+

รวมทุกคำสั่งซื้อสำเร็จ

+
+
+ + {/* Total Orders KPI */} +
+
+ จำนวนคำสั่งซื้อ + +
+
+
+ {fmtNum(totalOrders)} ครั้ง +
+

+ {totalOrders >= 2 ? 'ลูกค้าซื้อซ้ำ (Repeat Customer)' : 'ลูกค้าใหม่ (First-time)'} +

+
+
+
+ + {/* ── Orders History List ─────────────────────────────────────── */} +
+
+

+ + ประวัติคำสั่งซื้อทั้งหมด ({orders.length} รายการ) +

+
+ + {loading ? ( +
+
+ กำลังโหลดข้อมูลคำสั่งซื้อ... +
+ ) : orders.length === 0 ? ( +
+ + ไม่พบประวัติคำสั่งซื้อของลูกค้ารายนี้ +
+ ) : ( +
+ {orders.map((o, idx) => ( +
+ {/* Order Header */} +
+
+ + #{orders.length - idx} + +
+
+ {o.channel === 'tiktok' || primaryChannel === 'tiktok' ? ( + + {o.orderId} + + + ) : ( + + {o.orderId} + + )} + {getStatusBadge(o.status)} +
+
+ + + {o.date} + + {o.shop && ( + <> + • + ร้าน: {o.shop} + + )} +
+
+
+ +
+ ยอดรวมออเดอร์ + + {fmtBaht(o.amount)} + +
+
+ + {/* Items List */} +
+ + รายการสินค้า ({o.items?.length || 0} รายการ): + + {o.items && o.items.length > 0 ? ( + o.items.map((item, itemIdx) => ( +
+
+

{item.name}

+ {item.variation && ( + + ตัวเลือก: {item.variation} + + )} +
+
+ + x{item.quantity} •{' '} + + + {fmtBaht(item.price)} + +
+
+ )) + ) : ( +

ไม่มีข้อมูลรายละเอียดสินค้า

+ )} +
+ + {/* Extra Order Info (Tracking / Shipping) */} + {(o.trackingId || o.deliveryOption || o.shippingFee > 0) && ( +
+
+ {o.trackingId && ( + + + Tracking: {o.trackingId} + + )} + {o.deliveryOption && ({o.deliveryOption})} +
+ {o.shippingFee > 0 && ( + + ค่าจัดส่ง: {fmtBaht(o.shippingFee)} + + )} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/pages/analytics/RepeatCustomersActionPlanPage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersActionPlanPage.tsx new file mode 100644 index 0000000..1a8db49 --- /dev/null +++ b/apps/admin/src/pages/analytics/RepeatCustomersActionPlanPage.tsx @@ -0,0 +1,1250 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Target, + Sparkles, + AlertCircle, + AlertTriangle, + CheckCircle2, + Users, + Search, + RefreshCw, + Download, + Copy, + Check, + ChevronLeft, + ChevronRight, + ExternalLink, + MessageSquare, + Gift, + Flame, + Crown, + Tag, + Clock, + Zap, +} 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 PriorityFilter = 'all' | 'due_soon' | 'overdue' | 'vip' | 'healthy'; +type CRMStatusFilter = 'all' | 'pending' | 'contacted' | 'converted'; + +interface ActionPlanCustomer { + id: string; + identifier: string; + displayName: string; + primaryChannel: 'tiktok' | 'shopee' | 'lazada'; + location?: string; + orderCount: number; + totalSpent: number; + avgIntervalDays: number; + daysSinceLastOrder: number; + predictedNextOrderDate: string; + churnStatus: 'due_soon' | 'healthy' | 'churn_risk'; + lastOrderProduct?: string; + orders: any[]; +} + +export default function RepeatCustomersActionPlanPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [channel, setChannel] = useState('all'); + const [priorityFilter, setPriorityFilter] = useState('all'); + const [crmStatusFilter, setCrmStatusFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(20); + + const [loading, setLoading] = useState(true); + const [customers, setCustomers] = useState([]); + const [copiedId, setCopiedId] = useState(null); + const [importingId, setImportingId] = useState(null); + const [isBulkImporting, setIsBulkImporting] = useState(false); + + // CRM Imported Status Map from DB: key is `${primaryChannel}:${identifier.toLowerCase()}` + const [crmImportedMap, setCrmImportedMap] = useState< + Record< + string, + { + id: number; + crmId?: number; + status: 'pending' | 'contacted' | 'converted' | 'unresponsive'; + notes?: string; + promoCodeSent?: string; + discountOffered?: string; + } + > + >({}); + + // Fallback Local Storage CRM Map + const [localCrmStatusMap, setLocalCrmStatusMap] = useState>(() => { + try { + const saved = localStorage.getItem('homaroma_crm_action_status'); + return saved ? JSON.parse(saved) : {}; + } catch { + return {}; + } + }); + + // Check which customers are already imported in database + const checkImportedStatus = useCallback((customerList: ActionPlanCustomer[]) => { + if (!customerList || customerList.length === 0) return; + const keys = customerList.slice(0, 300).map((c) => ({ + channel: c.primaryChannel, + identifier: c.identifier, + })); + + fetch(`${API_BASE}/crm/check-imported`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + body: JSON.stringify({ keys }), + }) + .then((res) => res.json()) + .then((resData) => { + if (resData?.importedMap) { + setCrmImportedMap(resData.importedMap); + } + }) + .catch((err) => console.error('Failed to check imported status:', err)); + }, []); + + // Debounce search + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(handler); + }, [search]); + + // Fetch repeat customers timeline data + const fetchData = useCallback(() => { + setLoading(true); + const params = new URLSearchParams(); + if (channel !== 'all') params.set('channel', channel); + params.set('page', '1'); + params.set('limit', '500'); // fetch all repeat buyers to categorize client-side + + fetch(`${API_BASE}/business-analytics/repeat-customers/timeline?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((res) => res.json()) + .then((resData) => { + const rawList = resData?.customers || []; + const processed: ActionPlanCustomer[] = rawList.map((c: any) => { + const lastOrder = c.orders?.[c.orders.length - 1]; + const lastProd = lastOrder?.productNames?.[0] || ''; + return { + ...c, + lastOrderProduct: lastProd, + }; + }); + setCustomers(processed); + checkImportedStatus(processed); + }) + .catch((err) => console.error('Failed to load action plan data:', err)) + .finally(() => setLoading(false)); + }, [channel, checkImportedStatus]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Update CRM status in Database (and fallback in localStorage) + const updateCrmStatus = async ( + cust: ActionPlanCustomer, + newStatus: 'pending' | 'contacted' | 'converted' + ) => { + const mapKey = `${cust.primaryChannel}:${cust.identifier.toLowerCase()}`; + const existing = crmImportedMap[mapKey]; + + // Update local map right away for snappy UI + setLocalCrmStatusMap((prev) => { + const upd = { ...prev, [cust.id]: newStatus }; + try { + localStorage.setItem('homaroma_crm_action_status', JSON.stringify(upd)); + } catch (e) { + console.error(e); + } + return upd; + }); + + if (existing?.id) { + // Already in DB -> PATCH CRM Status directly + setCrmImportedMap((prev) => ({ + ...prev, + [mapKey]: { + ...prev[mapKey], + status: newStatus, + }, + })); + + try { + await fetch(`${API_BASE}/crm/customers/${existing.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + body: JSON.stringify({ status: newStatus }), + }); + } catch (e) { + console.error('Failed to patch CRM status in DB:', e); + } + } else { + // Auto-import into DB with the new status + await importSingleCustomer(cust, newStatus); + } + }; + + // Import single customer into DB + const importSingleCustomer = async ( + cust: ActionPlanCustomer, + overrideStatus?: 'pending' | 'contacted' | 'converted' + ) => { + const mapKey = `${cust.primaryChannel}:${cust.identifier.toLowerCase()}`; + setImportingId(cust.id); + const action = getActionRecommendation(cust); + const statusToUse = overrideStatus || localCrmStatusMap[cust.id] || 'pending'; + + const payload = { + customers: [ + { + channel: cust.primaryChannel, + identifier: cust.identifier, + displayName: cust.displayName, + location: cust.location || '', + orderCount: cust.orderCount, + totalSpent: cust.totalSpent, + avgIntervalDays: cust.avgIntervalDays, + firstOrderDate: cust.orders?.[0]?.date || '', + lastOrderDate: cust.orders?.[cust.orders.length - 1]?.date || '', + predictedNextOrderDate: cust.predictedNextOrderDate || '', + topProducts: cust.orders?.[cust.orders.length - 1]?.productNames || [], + initialStatus: statusToUse, + strategyType: action.strategyKey, + promoCode: action.promoCode, + discountOffered: action.suggestedDiscount, + }, + ], + }; + + try { + const res = await fetch(`${API_BASE}/crm/import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data?.results?.[0]) { + const item = data.results[0]; + setCrmImportedMap((prev) => ({ + ...prev, + [mapKey]: { + id: item.id, + crmId: item.crmId, + status: statusToUse, + promoCodeSent: action.promoCode, + discountOffered: action.suggestedDiscount, + }, + })); + } + } catch (err) { + console.error('Failed to import customer:', err); + } finally { + setImportingId(null); + } + }; + + // Helper for DateDiff (difference in days from today) + 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 ( + + เหลืออีก {diffDays} วัน + + ); + } else if (diffDays === 0) { + return ( + + ครบกำหนดวันนี้ + + ); + } else { + return ( + + เกินกำหนด {Math.abs(diffDays)} วัน + + ); + } + }; + + // 100% Deterministic Customer Strategy Classification + const getCustomerStrategy = (cust: ActionPlanCustomer): 'due_soon' | 'overdue' | 'vip' | 'healthy' => { + const diffDays = getDaysUntilPredicted(cust.predictedNextOrderDate); + const isVip = cust.totalSpent >= 4000 || cust.orderCount >= 5; + + // 1. Due Soon (Within 7 days before or up to 7 days overdue) + if (diffDays !== null && diffDays >= -7 && diffDays <= 7) { + return 'due_soon'; + } + + // 2. Overdue / Churn Risk (More than 7 days overdue) + if (diffDays !== null && diffDays < -7) { + return 'overdue'; + } + + // 3. VIP (Active / High-value buyer) + if (isVip) { + return 'vip'; + } + + // 4. Healthy / Nurturing (> 7 days in future) + return 'healthy'; + }; + + // Recommendation info based on strategy + const getActionRecommendation = (cust: ActionPlanCustomer) => { + const strategy = getCustomerStrategy(cust); + + switch (strategy) { + case 'vip': + return { + strategyKey: 'vip', + strategyTitle: '👑 VIP Rewards', + badgeColor: 'bg-purple-500/20 text-purple-300 border-purple-500/30', + recommendationText: 'ลูกค้าชั้นดี ยอดซื้อสูง มอบโค้ดลด Exclusive 15% หรือสิทธิ์รับของแถม Tester กลิ่นใหม่ล่าสุด', + promoCode: 'VIPHOMAROMA', + suggestedDiscount: 'ลด 15% หรือของแถม Tester กลิ่นใหม่', + urgency: 'HIGH', + }; + case 'due_soon': + return { + strategyKey: 'due_soon', + strategyTitle: '🔥 ถึงรอบซื้อซ้ำ (Restock Reminder)', + badgeColor: 'bg-amber-500/20 text-amber-300 border-amber-500/30', + recommendationText: 'ถึงรอบซื้อตามสถิติ ทักเตือนสินค้าใกล้หมด พร้อมโค้ดลด Restock 10% หรือส่งฟรี', + promoCode: 'RESTOCK10', + suggestedDiscount: 'ลด 10% หรือ ส่งฟรี', + urgency: 'URGENT', + }; + case 'overdue': + return { + strategyKey: 'overdue', + strategyTitle: '⚠️ เสี่ยงหลุดยอด (Win-Back Churn)', + badgeColor: 'bg-rose-500/20 text-rose-300 border-rose-500/30', + recommendationText: 'เลยรอบซื้อเฉลี่ยไปมาก เสี่ยงหลุดยอดขาย ดึงกลับด้วย Flash Voucher 20% หรือโค้ดลับ Welcome Back', + promoCode: 'COMEBACK20', + suggestedDiscount: 'ลด 20% หรือ Flash Coupon', + urgency: 'CRITICAL', + }; + case 'healthy': + default: + return { + strategyKey: 'healthy', + strategyTitle: '✨ สร้างความสัมพันธ์ (Nurturing)', + badgeColor: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30', + recommendationText: 'ระยะเวลายังอยู่ในรอบปกติ แนะนำกลิ่นหอมใหม่ หรือเทคนิคการดูแลเครื่องพ่นอโรมา', + promoCode: 'HOMAROMA', + suggestedDiscount: 'โปรโมชันประจำเดือน / แนะนำกลิ่นใหม่', + urgency: 'LOW', + }; + } + }; + + // Generate Message Template for Copying + const getCustomerMessageTemplate = (cust: ActionPlanCustomer) => { + const action = getActionRecommendation(cust); + const prodName = cust.lastOrderProduct || 'น้ำหอมปรับอากาศ/น้ำมันหอมระเหย HomAroma'; + const cleanName = cust.displayName.replace(/\s*\(\d+\)$/, ''); + + if (action.strategyKey === 'vip') { + return `สวัสดีครับคุณ ${cleanName} ✨ ขอบพระคุณที่ไว้วางใจ HomAroma เสมอมาครับ\n\nวันนี้ทางร้านขอมอบสิทธิ์ VIP สุดพิเศษสำหรับคุณโดยเฉพาะ! รับส่วนลด 15% หรือสิทธิ์รับ Tester กลิ่นใหม่ล่าสุดฟรี เมื่อสั่งซื้อเติม ${prodName} รอบถัดไปนะครับ 🌿\n\nแจ้งโค้ด: [${action.promoCode}] กับแอดมินได้เลยครับ 😊`; + } + + if (action.strategyKey === 'due_soon') { + return `สวัสดีครับคุณ ${cleanName} 🌿 จาก HomAroma นะครับ\n\n${prodName} ที่สั่งซื้อไปก่อนหน้านี้ใกล้หมดหรือยังครับ? ตอนนี้ทางร้านมีโค้ดลดพิเศษสำหรับลูกค้าประจำ ลดเพิ่ม 10% [${action.promoCode}] หรือรับสิทธิ์ส่งฟรีทันทีครับ ✨\n\nสนใจเติมสินค้ากลิ่นเดิมหรือลองกลิ่นใหม่ ทักข้อความหาเราได้เลยนะครับ!`; + } + + if (action.strategyKey === 'overdue') { + return `สวัสดีครับคุณ ${cleanName} 🌸 HomAroma คิดถึงคุณลูกค้านะครับ!\n\nวันนี้เรามีโปรโมชัน Welcome Back สุดคุ้ม ลดพิเศษ 20% ทันที [${action.promoCode}] สำหรับการสั่งซื้อครั้งถัดไป ไม่ว่าจะเป็น ${prodName} หรือกลิ่นใหม่ยอดฮิต 🌿\n\nโค้ดมีจำนวนจำกัด ทักแชทสั่งซื้อได้เลยนะครับ 😊`; + } + + return `สวัสดีครับคุณ ${cleanName} 🌿 HomAroma ขอขอบพระคุณสำหรับคำสั่งซื้อที่ผ่านมานะครับ หากคุณลูกค้าต้องการคำแนะนำกลิ่นหอมเพิ่มเติมหรือการใช้งานเครื่องพ่น ทักสอบถามเราได้ตลอดเลยนะครับ ✨`; + }; + + const handleCopyTemplate = (cust: ActionPlanCustomer) => { + const template = getCustomerMessageTemplate(cust); + navigator.clipboard.writeText(template); + setCopiedId(cust.id); + setTimeout(() => { + setCopiedId((curr) => (curr === cust.id ? null : curr)); + }, 2500); + }; + + const handleCustomerClick = (cust: ActionPlanCustomer) => { + const mapKey = `${cust.primaryChannel}:${cust.identifier.toLowerCase()}`; + const crmItem = crmImportedMap[mapKey]; + + if (crmItem?.id) { + navigate(`/crm/marketplace-customers/${crmItem.id}`); + return; + } + + 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)}` + ); + }; + + // Filtered customers with strict, accurate strategy match + const filteredCustomers = useMemo(() => { + return customers.filter((c) => { + // Channel filter + if (channel !== 'all' && c.primaryChannel !== channel) { + return false; + } + + // Search + if (debouncedSearch) { + const query = debouncedSearch.toLowerCase(); + const matchesName = c.displayName.toLowerCase().includes(query); + const matchesId = c.identifier.toLowerCase().includes(query); + const matchesProd = (c.lastOrderProduct || '').toLowerCase().includes(query); + const matchesLoc = (c.location || '').toLowerCase().includes(query); + if (!matchesName && !matchesId && !matchesProd && !matchesLoc) return false; + } + + // Priority / Strategy filter + if (priorityFilter !== 'all') { + const strategy = getCustomerStrategy(c); + if (strategy !== priorityFilter) return false; + } + + // CRM status filter + const mapKey = `${c.primaryChannel}:${c.identifier.toLowerCase()}`; + const crmStatus = crmImportedMap[mapKey]?.status || localCrmStatusMap[c.id] || 'pending'; + if (crmStatusFilter !== 'all' && crmStatus !== crmStatusFilter) { + return false; + } + + return true; + }); + }, [customers, channel, debouncedSearch, priorityFilter, crmStatusFilter, crmImportedMap, localCrmStatusMap]); + + // Pagination + const totalItems = filteredCustomers.length; + const totalPages = Math.max(1, Math.ceil(totalItems / limit)); + const paginatedCustomers = useMemo(() => { + const start = (page - 1) * limit; + return filteredCustomers.slice(start, start + limit); + }, [filteredCustomers, page, limit]); + + // Overall KPI counts matching channel filter + const kpiStats = useMemo(() => { + let dueSoonCount = 0; + let overdueCount = 0; + let vipCount = 0; + let healthyCount = 0; + let contactedCount = 0; + let convertedCount = 0; + + const pool = channel === 'all' ? customers : customers.filter((c) => c.primaryChannel === channel); + + pool.forEach((c) => { + const strategy = getCustomerStrategy(c); + if (strategy === 'due_soon') dueSoonCount++; + else if (strategy === 'overdue') overdueCount++; + else if (strategy === 'vip') vipCount++; + else if (strategy === 'healthy') healthyCount++; + + const mapKey = `${c.primaryChannel}:${c.identifier.toLowerCase()}`; + const status = crmImportedMap[mapKey]?.status || localCrmStatusMap[c.id]; + if (status === 'contacted') contactedCount++; + if (status === 'converted') convertedCount++; + }); + + return { + dueSoonCount, + overdueCount, + vipCount, + healthyCount, + contactedCount, + convertedCount, + totalCount: pool.length, + }; + }, [customers, channel, crmImportedMap, localCrmStatusMap]); + + // Export CSV + const handleExportCsv = () => { + if (filteredCustomers.length === 0) return; + + const headers = [ + 'Customer Identifier', + 'Display Name', + 'Primary Channel', + 'Order Count', + 'Total Spent (THB)', + 'Last Order Date', + 'Avg Interval (Days)', + 'Predicted Next Date', + 'Recommended Action', + 'Suggested Promo Code', + 'Suggested Discount', + 'CRM Plan Status', + ]; + + const rows = filteredCustomers.map((c) => { + const action = getActionRecommendation(c); + const crmStatus = crmStatusMap[c.id] || 'pending'; + return [ + `"${c.identifier || ''}"`, + `"${c.displayName || ''}"`, + `"${c.primaryChannel || ''}"`, + c.orderCount, + c.totalSpent, + `"${c.predictedNextOrderDate || ''}"`, + c.avgIntervalDays, + `"${c.predictedNextOrderDate || ''}"`, + `"${action.strategyTitle}"`, + `"${action.promoCode}"`, + `"${action.suggestedDiscount}"`, + `"${crmStatus}"`, + ]; + }); + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `homaroma_repeat_crm_action_plan_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const getChannelBadge = (ch: string) => { + if (ch === 'tiktok') return TikTok; + if (ch === 'shopee') return Shopee; + return Lazada; + }; + + return ( +
+ {/* ── Top Header Bar ────────────────────────────────────────── */} +
+
+
+
+ +
+
+

+ {t('repeatCustomersActionPlan.title', 'แผนปฏิบัติการข้อเสนอ & โปรโมชันลูกค้าซื้อซ้ำ (CRM Action Plan)')} +

+

+ วิเคราะห์รอบการซื้อซ้ำอัตโนมัติ พร้อมข้อเสนอแนะโปรโมชัน/โค้ดลดที่ตรงจุด เพื่อป้องกันยอดขายหลุดและเพิ่ม Repurchase Rate +

+
+
+
+ +
+ + + {/* Bulk Import to CRM Button */} + + + +
+
+ + {/* ── Nav Tabs Bar ───────────────────────────────────────────── */} + + + {/* ── Action Strategy KPI Cards ──────────────────────────────── */} +
+ {/* Due Soon Card */} +
{ + setPriorityFilter((curr) => (curr === 'due_soon' ? 'all' : 'due_soon')); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + priorityFilter === 'due_soon' + ? 'border-amber-400 ring-2 ring-amber-400/50 bg-amber-950/25 shadow-lg shadow-amber-500/10' + : 'border-amber-500/20 hover:border-amber-500/40' + }`} + > +
+ 🔥 ถึงรอบ Restock (7 วัน) + +
+
+ {fmtNum(kpiStats.dueSoonCount)} คน +
+

เตือนก่อนของหมด (โค้ดลด 10%)

+
+ + {/* Overdue Win-back */} +
{ + setPriorityFilter((curr) => (curr === 'overdue' ? 'all' : 'overdue')); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + priorityFilter === 'overdue' + ? 'border-rose-400 ring-2 ring-rose-400/50 bg-rose-950/25 shadow-lg shadow-rose-500/10' + : 'border-rose-500/20 hover:border-rose-500/40' + }`} + > +
+ ⚠️ เสี่ยงหลุดยอด (Win-Back) + +
+
+ {fmtNum(kpiStats.overdueCount)} คน +
+

เกินรอบเฉลี่ย (Flash Voucher 20%)

+
+ + {/* VIP Rewards */} +
{ + setPriorityFilter((curr) => (curr === 'vip' ? 'all' : 'vip')); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + priorityFilter === 'vip' + ? 'border-purple-400 ring-2 ring-purple-400/50 bg-purple-950/25 shadow-lg shadow-purple-500/10' + : 'border-purple-500/20 hover:border-purple-500/40' + }`} + > +
+ 👑 VIP Rewards + +
+
+ {fmtNum(kpiStats.vipCount)} คน +
+

ยอด ≥ ฿4,000 หรือ ≥ 5 ออเดอร์

+
+ + {/* Healthy / Nurturing */} +
{ + setPriorityFilter((curr) => (curr === 'healthy' ? 'all' : 'healthy')); + setPage(1); + }} + className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${ + priorityFilter === 'healthy' + ? 'border-emerald-400 ring-2 ring-emerald-400/50 bg-emerald-950/25 shadow-lg shadow-emerald-500/10' + : 'border-emerald-500/20 hover:border-emerald-500/40' + }`} + > +
+ ✨ ปกติ (Active Nurture) + +
+
+ {fmtNum(kpiStats.healthyCount)} คน +
+

ยังไม่ถึงรอบซื้อ (สร้างสัมพันธ์)

+
+
+ + {/* ── Filters & Search Controls ──────────────────────────────── */} +
+ {/* Priority Filter */} +
+ + + + + +
+ + {/* Channel Selection */} +
+ + + + +
+ + {/* CRM Status Filter */} +
+ สถานะ CRM: + + + + +
+ + {/* Search */} +
+ + 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" + /> +
+
+ + {/* ── Action Plan Table List ─────────────────────────────────── */} +
+ {loading ? ( +
+
+ กำลังวิเคราะห์ข้อมูลและสร้าง Action Plan รายบุคคล... +
+ ) : paginatedCustomers.length === 0 ? ( +
+ + ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไขที่เลือก +
+ ) : ( +
+ {paginatedCustomers.map((cust, idx) => { + const action = getActionRecommendation(cust); + const mapKey = `${cust.primaryChannel}:${cust.identifier.toLowerCase()}`; + const crmStatus = crmImportedMap[mapKey]?.status || localCrmStatusMap[cust.id] || 'pending'; + const isCopied = copiedId === cust.id; + + return ( +
+ {/* Top Bar: Customer & Cycle Status */} +
+
+ {/* Avatar */} +
handleCustomerClick(cust)} + className="w-10 h-10 rounded-xl bg-slate-800/90 border border-white/10 flex items-center justify-center font-bold text-sm text-purple-400 cursor-pointer hover:bg-purple-600/20 hover:border-purple-500/40 hover:scale-105 transition-all" + title="คลิกเพื่อดูรายละเอียดลูกค้า" + > + {cust.displayName.slice(0, 2)} +
+ +
+
+ handleCustomerClick(cust)} + className="font-bold text-base text-white hover:text-purple-300 hover:underline cursor-pointer transition-colors" + title="คลิกเพื่อดูรายละเอียดลูกค้า" + > + {cust.displayName} + + {getChannelBadge(cust.primaryChannel)} + + {action.strategyTitle} + +
+ +
+ + ยอดสะสม {fmtBaht(cust.totalSpent)} + + • + + {cust.orderCount} คำสั่งซื้อ + + {cust.location && ( + <> + • + {cust.location} + + )} +
+
+
+ + {/* Cycle Indicators */} +
+
+ รอบซื้อเฉลี่ย + + {cust.avgIntervalDays} วัน + +
+
+
+ คาดการณ์รอบถัดไป +
+ + {cust.predictedNextOrderDate} + + {renderPredictedDateDiff(cust.predictedNextOrderDate)} +
+
+
+
+ + {/* Middle Bar: Recommended Offer & Actionable Strategy */} +
+
+ + + กลยุทธ์ที่แนะนำ: + +

{action.recommendationText}

+
+ +
+ + + ข้อเสนอ / โค้ดลดที่แนะนำ: + +
+ + {action.promoCode} + + {action.suggestedDiscount} +
+
+ +
+ + + สินค้าเดิมที่ควรเสนอเติม: + +

+ {cust.lastOrderProduct || 'น้ำมันหอมระเหย / น้ำหอมปรับอากาศ'} +

+
+
+ + {/* Bottom Actions: CRM Status Toggle, Import Button & Quick Copy Template */} +
+ {/* CRM Status Toggle */} +
+ สถานะ CRM: +
+ + + +
+
+ + {/* Action Buttons */} +
+ {/* Import Customer Button / Badge */} + {crmImportedMap[`${cust.primaryChannel}:${cust.identifier.toLowerCase()}`]?.crmId ? ( + + + นำเข้า CRM แล้ว + + ) : ( + + )} + + {/* Copy Chat Message Template */} + + + {/* View Customer Details Full Page */} + +
+
+
+ ); + })} +
+ )} +
+ + {/* ── Pagination Bar ─────────────────────────────────────────── */} + {totalPages > 1 && ( +
+
+ + กำลังแสดงหน้า {page} จาก{' '} + {totalPages} หน้า (ทั้งหมด {fmtNum(totalItems)} รายการ) + +
+ แสดง: + +
+
+ +
+ + + + {page} / {totalPages} + + + +
+
+ )} +
+ ); +} diff --git a/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx index 14e0d4e..04cf18b 100644 --- a/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx +++ b/apps/admin/src/pages/analytics/RepeatCustomersPage.tsx @@ -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('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 ( + + เหลืออีก {diffDays} วัน + + ); + } else if (diffDays === 0) { + return ( + + ครบกำหนดวันนี้ + + ); + } else { + return ( + + เกินกำหนด {Math.abs(diffDays)} วัน + + ); + } + }; + + 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 (
{/* ── Top Header Bar ────────────────────────────────────────── */} @@ -733,6 +786,7 @@ export default function RepeatCustomersPage() { ยอดเฉลี่ย / ออเดอร์ ซื้อล่าสุด รอบซื้อซ้ำเฉลี่ย + คาดการณ์รอบถัดไป สินค้าที่ซื้อบ่อย การจัดการ @@ -740,14 +794,14 @@ export default function RepeatCustomersPage() { {loading ? ( - +
กำลังประมวลผลข้อมูลลูกค้าซื้อซ้ำ... ) : !data?.customers || data.customers.length === 0 ? ( - + ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข @@ -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 (
- + handleCustomerClick(cust)} + className="font-semibold text-slate-100 text-sm hover:text-indigo-300 hover:underline cursor-pointer transition-colors" + title="คลิกเพื่อดูรายละเอียดลูกค้า" + > {cust.displayName} {cust.location && ( @@ -831,6 +895,21 @@ export default function RepeatCustomersPage() { )} + + {predictedDate ? ( +
+ + {predictedDate} + +
+ {renderPredictedDateDiff(predictedDate)} +
+
+ ) : ( + - + )} + +
{cust.topProducts?.map((prod: string, pIdx: number) => ( @@ -847,10 +926,10 @@ export default function RepeatCustomersPage() { diff --git a/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx b/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx index fc7a69e..078a2b4 100644 --- a/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx +++ b/apps/admin/src/pages/analytics/RepeatCustomersTimelinePage.tsx @@ -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('all'); const [churnStatus, setChurnStatus] = useState('all'); @@ -34,7 +35,6 @@ export default function RepeatCustomersTimelinePage() { const [loading, setLoading] = useState(true); const [data, setData] = useState(null); - const [selectedCustomer, setSelectedCustomer] = useState(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 ( + + เหลืออีก {diffDays} วัน + + ); + } else if (diffDays === 0) { + return ( + + ครบกำหนดวันนี้ + + ); + } else { + return ( + + เกินกำหนด {Math.abs(diffDays)} วัน + + ); + } + }; + + 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 TikTok Shop; if (ch === 'shopee') return Shopee; @@ -365,12 +414,20 @@ export default function RepeatCustomersTimelinePage() { {/* Card Top: Customer Identity & Status */}
-
+
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)}
- + handleCustomerClick(cust)} + className="font-bold text-base text-white hover:text-indigo-300 hover:underline cursor-pointer transition-colors" + title="คลิกเพื่อดูรายละเอียดลูกค้า" + > {cust.displayName} {getChannelBadge(cust.primaryChannel)} @@ -396,7 +453,7 @@ export default function RepeatCustomersTimelinePage() {
{/* Right side: Cycle metrics */} -
+
รอบซื้อเฉลี่ย @@ -413,9 +470,12 @@ export default function RepeatCustomersTimelinePage() {
คาดการณ์รอบถัดไป - - {cust.predictedNextOrderDate} - +
+ + {cust.predictedNextOrderDate} + + {renderPredictedDateDiff(cust.predictedNextOrderDate)} +
@@ -450,8 +510,8 @@ export default function RepeatCustomersTimelinePage() { {/* Order Node */}
setSelectedCustomer(cust)} - title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)})`} + onClick={() => handleCustomerClick(cust)} + title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)}) — คลิกเพื่อดูรายละเอียด`} > {/* Node Circle */}
-
- -
-
- - รอบถัดไป - - - ~{cust.predictedNextOrderDate.slice(5, 10)} - -
-
+ {(() => { + const diffDays = getDaysUntilPredicted(cust.predictedNextOrderDate); + return ( +
+
+ +
+
+ + รอบถัดไป + + + ~{cust.predictedNextOrderDate.slice(5, 10)} + + {diffDays !== null && ( + 0 + ? 'text-emerald-400' + : diffDays === 0 + ? 'text-amber-300 animate-pulse' + : 'text-rose-400' + }`} + > + {diffDays > 0 + ? `(อีก ${diffDays} วัน)` + : diffDays === 0 + ? '(วันนี้)' + : `(เลย ${Math.abs(diffDays)} วัน)`} + + )} +
+
+ ); + })()}
@@ -543,11 +625,11 @@ export default function RepeatCustomersTimelinePage() {
@@ -603,14 +685,6 @@ export default function RepeatCustomersTimelinePage() {
)} - - {/* ── Order Detail Modal ─────────────────────────────────────── */} - {selectedCustomer && ( - setSelectedCustomer(null)} - /> - )}
); } diff --git a/apps/admin/src/pages/crm/MarketplaceCrmPage.tsx b/apps/admin/src/pages/crm/MarketplaceCrmPage.tsx new file mode 100644 index 0000000..c677f26 --- /dev/null +++ b/apps/admin/src/pages/crm/MarketplaceCrmPage.tsx @@ -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('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [strategyFilter, setStrategyFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(20); + + const [loading, setLoading] = useState(true); + const [customers, setCustomers] = useState([]); + 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(null); + const [editNotes, setEditNotes] = useState(''); + const [editPromoCode, setEditPromoCode] = useState(''); + const [editDiscount, setEditDiscount] = useState(''); + const [editStatus, setEditStatus] = useState('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 TikTok; + if (ch === 'shopee') return Shopee; + return Lazada; + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'converted': + return ( + + + ซื้อซ้ำสำเร็จ + + ); + case 'contacted': + return ( + + + ส่งโปรแล้ว + + ); + case 'unresponsive': + return ( + + + ไม่สนใจ/ไม่ตอบ + + ); + case 'pending': + default: + return ( + + + รอดำเนินการ + + ); + } + }; + + const getStrategyBadge = (type: string) => { + switch (type) { + case 'due_restock': + case 'due_soon': + return 🔥 เตือนซื้อซ้ำ; + case 'win_back': + case 'overdue': + return ⚠️ Win-Back; + case 'vip_reward': + case 'vip': + return 👑 VIP Reward; + default: + return ✨ Nurturing; + } + }; + + return ( +
+ {/* ── Top Header Bar ────────────────────────────────────────── */} +
+
+
+
+ +
+
+

+ {t('marketplaceCrm.title', 'CRM ติดตามลูกค้า Marketplace')} +

+

+ ฐานข้อมูลบริหารจัดการ ติดตามสถานะ และบันทึกประวัติการทักหาลูกค้าที่นำเข้ามาจาก Marketplace +

+
+
+
+ +
+ + + +
+
+ + {/* ── Nav Tabs Bar ───────────────────────────────────────────── */} + + + {/* ── CRM Overview KPI Cards ─────────────────────────────────── */} +
+
+
+ ลูกค้าใน CRM ทั้งหมด + +
+
+ {fmtNum(summary.totalInCrm)} คน +
+

ยอดซื้อสะสม {fmtBaht(summary.totalRevenueInCrm)}

+
+ +
{ + 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' + }`} + > +
+ ⏳ รอดำเนินการ + +
+
+ {fmtNum(summary.pendingCount)} คน +
+

ยังไม่ได้ทักหรือส่งข้อเสนอ

+
+ +
{ + 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' + }`} + > +
+ 💬 ส่งโปรแล้ว + +
+
+ {fmtNum(summary.contactedCount)} คน +
+

รอฟีดแบ็ค / ออเดอร์ซื้อซ้ำ

+
+ +
{ + 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' + }`} + > +
+ ✅ ซื้อซ้ำสำเร็จ + +
+
+ {fmtNum(summary.convertedCount)} คน +
+

ปิดการขายสำเร็จ

+
+ +
+
+ 📈 Conversion Rate + +
+
+ {summary.conversionRate}% +
+

สำเร็จเทียบกับกลุ่มที่ทักไป

+
+
+ + {/* ── Filters & Search Controls ──────────────────────────────── */} +
+
+ {/* Channel Filter */} +
+ {(['all', 'tiktok', 'shopee', 'lazada'] as ChannelType[]).map((ch) => ( + + ))} +
+ + {/* Status Filter Tabs */} +
+ {[ + { id: 'all', label: 'ทั้งหมด' }, + { id: 'pending', label: '⏳ รอดำเนินการ' }, + { id: 'contacted', label: '💬 ส่งโปรแล้ว' }, + { id: 'converted', label: '✅ ซื้อซ้ำสำเร็จ' }, + { id: 'unresponsive', label: '❌ ไม่สนใจ' }, + ].map((st) => ( + + ))} +
+
+ + {/* Search */} +
+ + 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" + /> +
+
+ + {/* ── CRM Table ──────────────────────────────────────────────── */} +
+ {loading ? ( +
+
+ กำลังโหลดข้อมูลลูกค้าในระบบ CRM... +
+ ) : customers.length === 0 ? ( +
+ +

ยังไม่มีลูกค้าในรายการ CRM ตามเงื่อนไขนี้

+

+ คุณสามารถกดปุ่ม "Import เข้า CRM" ได้ที่หน้า Action Plan +

+
+ ) : ( +
+ + + + + + + + + + + + + + {customers.map((c) => { + const crm = c.crm; + const curStatus = crm?.status || 'pending'; + + return ( + + {/* Customer Info */} + + + {/* Stats */} + + + {/* Strategy */} + + + {/* Quick Status Dropdown / Buttons */} + + + {/* Promo & Discount */} + + + {/* Notes Preview */} + + + {/* Actions */} + + + ); + })} + +
ลูกค้า & ช่องทางสถิติซื้อกลยุทธ์สถานะ CRMข้อเสนอ / โค้ดลดบันทึกโน้ต (Notes)การจัดการ
+
+
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)} +
+
+
+ navigate(`/crm/marketplace-customers/${c.id}`)} + className="font-semibold text-white hover:text-purple-300 hover:underline cursor-pointer" + title="เปิดดูรายละเอียด CRM ลูกค้าเต็มหน้า" + > + {c.displayName} + + {getChannelBadge(c.channel)} +
+ + {c.identifier} {c.location ? `• ${c.location}` : ''} + +
+
+
+
+ {fmtBaht(c.totalSpent)} + + {c.orderCount} ออเดอร์ (รอบ {c.avgIntervalDays} วัน) + +
+
+ {getStrategyBadge(crm?.strategyType || 'due_restock')} + +
+ +
+
+ {crm?.promoCodeSent ? ( +
+ + {crm.promoCodeSent} + + {crm.discountOffered && ( + {crm.discountOffered} + )} +
+ ) : ( + - + )} +
+

+ {crm?.notes || ไม่มีบันทึก} +

+ {crm?.activities?.[0] && ( + + ล่าสุด: {crm.activities[0].content} + + )} +
+
+ + + +
+
+
+ )} +
+ + {/* ── Pagination Bar ─────────────────────────────────────────── */} + {pagination.totalPages > 1 && ( +
+ + กำลังแสดงหน้า {pagination.page} จาก{' '} + {pagination.totalPages} หน้า (ทั้งหมด{' '} + {fmtNum(pagination.totalCount)} รายการ) + + +
+ + + + {page} / {pagination.totalPages} + + + +
+
+ )} + + {/* ── Edit Note & Activity Modal ─────────────────────────────── */} + {editingCustomer && ( +
+
+ {/* Modal Header */} +
+
+

+ + จัดการบันทึก CRM: {editingCustomer.displayName} +

+ + {editingCustomer.channel.toUpperCase()} • {editingCustomer.identifier} + +
+ +
+ + {/* Modal Content */} +
+ {/* Status & Follow-up Date */} +
+
+ + +
+ +
+ + 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" + /> +
+
+ + {/* Promo Code & Discount */} +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ + {/* Notes */} +
+ +