commit f2179cfe5729e60f025c538cd991481b809bf782 Author: Your Name Date: Wed Jun 3 08:39:12 2026 +0700 Initial commit for homaroma diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a2b7f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun.lockb + +# Dependency directories +node_modules/ +jspm_packages/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc gitignore +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#testing) +.grunt + +# Bower dependency directory (http://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1.x command line compiler build cache +third-party/ + +# Monorepo build outputs +dist/ +build/ +.next/ +.nuxt/ +out/ +.vercel/ +.output/ + +# IDE files +.vscode/* +!.vscode/extensions.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS metadata +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.env + +# SQLite +*.db +*.db-journal + +# Prisma +/apps/backend/prisma/dev.db + +# Bun +.bun-build diff --git a/.taskboard-token b/.taskboard-token new file mode 100644 index 0000000..d2feb9b --- /dev/null +++ b/.taskboard-token @@ -0,0 +1 @@ +ag_d909195cfceb49ef9f965aaf59b80003 diff --git a/PRISMA7_GUIDE.md b/PRISMA7_GUIDE.md new file mode 100644 index 0000000..97ae5ff --- /dev/null +++ b/PRISMA7_GUIDE.md @@ -0,0 +1,251 @@ +# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป) + + xx +> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน +> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว** + +--- + +## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7 + +| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) | +|---|---|---| +| `DATABASE_URL` ใน `schema.prisma` | ✅ ใช้ได้ | ❌ ใช้ไม่ได้ | +| `new PrismaClient()` ไม่มี options | ✅ อ่าน `.env` เอง | ❌ Error ทันที | +| Config ฐานข้อมูล | อยู่ใน `schema.prisma` | ย้ายไปที่ `prisma.config.ts` | +| วิธีเชื่อมต่อ | ผ่าน URL ใน `.env` | ผ่าน **Driver Adapter** | + +--- + +## 2. โครงสร้างไฟล์ที่ถูกต้องสำหรับ Prisma 7 + +``` +apps/backend/ +├── prisma/ +│ ├── schema.prisma # Models เท่านั้น — ห้ามใส่ url ใน datasource +│ ├── seed.js # Seed file (ใช้ .js ไม่ใช่ .ts เพื่อหลีกเลี่ยง ts-node issues) +│ └── migrations/ # Migration files +├── prisma.config.ts # ← จุดตั้งค่าหลักของ Prisma 7 +├── .env # DATABASE_URL อยู่ที่นี่ +└── package.json +``` + +--- + +## 3. `schema.prisma` — ห้ามใส่ `url` + +```prisma +// ✅ CORRECT — Prisma 7 +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + // ❌ ห้ามใส่บรรทัดนี้: url = env("DATABASE_URL") +} + +model Scent { + id Int @id @default(autoincrement()) + name String + size String? + sku String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +--- + +## 4. `prisma.config.ts` — ไฟล์หลักของ Prisma 7 + +```typescript +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "node prisma/seed.js", // ← ชี้ไปที่ seed.js (ไม่ใช่ seed.ts) + }, + datasource: { + url: process.env["DATABASE_URL"], // ← URL อยู่ที่นี่แทน schema.prisma + }, +}); +``` + +--- + +## 5. `seed.js` — Template ที่ใช้งานได้จริง + +> **ทำไมใช้ `.js` ไม่ใช่ `.ts`:** `ts-node` ใน environment นี้ไม่แสดง output ทำให้ debug ยาก ใช้ `.js` แทนเพื่อความเสถียร + +```javascript +// seed.js — CommonJS format +const path = require('path'); + +// โหลด .env ก่อนทุกอย่าง +require('dotenv').config({ path: path.join(__dirname, '../.env') }); + +const { PrismaClient } = require('@prisma/client'); +const { PrismaPg } = require('@prisma/adapter-pg'); // ← จำเป็นสำหรับ Prisma 7 + +// ✅ วิธีเดียวที่ถูกต้องใน Prisma 7 — ต้องส่ง adapter เข้าไป +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); + +const data = [ + // ใส่ข้อมูลที่ต้องการ seed ที่นี่ + { name: 'ตัวอย่าง', size: '120ml', sku: '120-ตัวอย่าง-120ml' }, +]; + +async function main() { + process.stdout.write('🚀 Starting seeding...\n'); + + for (const item of data) { + await prisma.scent.upsert({ + where: { sku: item.sku }, // ← ใช้ unique field + update: {}, // ← ถ้ามีอยู่แล้วไม่ต้อง update + create: item, + }); + process.stdout.write(`✅ ${item.name} (${item.size})\n`); + } + + process.stdout.write(`✨ Done! Total: ${data.length} records\n`); +} + +main() + .catch((e) => { + process.stderr.write(`❌ Error: ${e.message}\n`); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); +``` + +--- + +## 6. Packages ที่ต้อง Install + +```bash +# Dependencies หลัก +npm i @prisma/client @prisma/adapter-pg pg + +# Dev dependencies +npm i -D prisma dotenv +``` + +> **ต่างจากเดิม:** Prisma 7 ต้องการ `@prisma/adapter-pg` ด้วย — ขาดไม่ได้! + +--- + +## 7. วิธีรัน Seed + +```bash +# วิธีที่ 1: รันตรงๆ (แนะนำสำหรับ debug) +cd apps/backend +node prisma/seed.js + +# วิธีที่ 2: ผ่าน Prisma CLI (ต้องตั้งค่าใน prisma.config.ts แล้ว) +cd apps/backend +npx prisma db seed +``` + +--- + +## 8. Error ที่พบบ่อยและวิธีแก้ + +### ❌ Error: `url is no longer supported in schema files` + +``` +The datasource property `url` is no longer supported in schema files. +``` + +**แก้:** ลบ `url = env("DATABASE_URL")` ออกจาก `schema.prisma` +**ย้ายไปใส่:** `prisma.config.ts` → `datasource.url` + +--- + +### ❌ Error: `PrismaClient needs to be constructed with a non-empty, valid PrismaClientOptions` + +``` +PrismaClientInitializationError: `PrismaClient` needs to be constructed with... +``` + +**แก้:** ต้องส่ง `adapter` เข้าไปใน constructor เสมอ + +```javascript +// ❌ ผิด +const prisma = new PrismaClient(); + +// ✅ ถูก +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); +``` + +--- + +### ❌ Error: `Unknown property datasourceUrl provided to PrismaClient constructor` + +``` +PrismaClientConstructorValidationError: Unknown property datasourceUrl... +``` + +**แก้:** Prisma 7 ไม่รับ `datasourceUrl` แล้ว ต้องใช้ `adapter` แทน + +```javascript +// ❌ ผิด (เคยใช้ได้ใน Prisma 6) +const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); + +// ✅ ถูก +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); +``` + +--- + +### ❌ Error: `⚠️ No seed command configured` + +``` +⚠️ No seed command configured +``` + +**แก้:** เพิ่ม `seed` ใน `prisma.config.ts` (ไม่ใช่แค่ใน `package.json` อีกต่อไป) + +```typescript +migrations: { + path: "prisma/migrations", + seed: "node prisma/seed.js", // ← ต้องมีบรรทัดนี้ +}, +``` + +--- + +## 9. Checklist ก่อนทำ Seed ทุกครั้ง + +``` +□ 1. schema.prisma → datasource ไม่มี url +□ 2. prisma.config.ts → datasource.url ตั้งค่าไว้ +□ 3. prisma.config.ts → migrations.seed ชี้ถูก +□ 4. .env มี DATABASE_URL ครบถ้วน +□ 5. ติดตั้ง @prisma/adapter-pg และ pg แล้ว +□ 6. seed.js ใช้ PrismaPg adapter ในการสร้าง PrismaClient +□ 7. รัน npx prisma db push หรือ migrate ก่อน seed +□ 8. รัน node prisma/seed.js เพื่อ seed +``` + +--- + +## 10. สรุปภาพรวม Flow + +``` +.env (DATABASE_URL) + ↓ +prisma.config.ts (datasource.url) + ↓ npx prisma db push/migrate +schema.prisma (Models) → Database Tables + ↓ node prisma/seed.js +seed.js (PrismaPg adapter + PrismaClient) → Insert data → DB +``` diff --git a/PROJECT_DETAILS.md b/PROJECT_DETAILS.md new file mode 100644 index 0000000..28686fd --- /dev/null +++ b/PROJECT_DETAILS.md @@ -0,0 +1,122 @@ +# รายละเอียดโปรเจกต์ iRelax Aroma + +โปรเจกต์นี้เป็นระบบ E-commerce สำหรับผลิตภัณฑ์เครื่องหอมและน้ำมันหอมระเหย (Aroma & Essential Oils) โดยมีการออกแบบสถาปัตยกรรมแบบ Monorepo ที่แบ่งส่วนการทำงานออกเป็น 4 ส่วนหลักในโฟลเดอร์ `apps/` ดังนี้: + +--- + +## 1. Backend (`apps/backend`) +**ศูนย์กลางการประมวลผลและฐานข้อมูล** + +* **เทคโนโลยีที่ใช้:** Bun, Elysia.js, Prisma 7, PostgreSQL +* **การทำงาน:** ทำหน้าที่เป็น RESTful API Server ที่จัดการข้อมูลทั้งหมดของระบบ ตั้งแต่การตรวจสอบสิทธิ์ (Authentication), การจัดการสินค้า (Products), หมวดหมู่ (Categories), คำสั่งซื้อ (Orders) ไปจนถึงการตั้งค่าต่างๆ ของร้านค้า +* **เป้าหมาย:** เพื่อให้เป็นจุดเชื่อมต่อข้อมูลเดียว (Single Source of Truth) ที่มีความรวดเร็วสูงด้วย Bun และมีความปลอดภัยในการเข้าถึงข้อมูล +* **การเชื่อมโยง:** เชื่อมต่อกับฐานข้อมูล PostgreSQL และให้บริการ API แก่ `admin`, `ssr`, และ `website` + +## 2. Admin Portal (`apps/admin`) +**ระบบจัดการหลังบ้าน (Back-office)** + +* **เทคโนโลยีที่ใช้:** React, Vite, Tailwind CSS +* **การทำงาน:** เป็น Web Application สำหรับผู้ดูแลระบบ (Admin) เพื่อใช้ในการจัดการสต็อกสินค้า, ดูรายงานยอดขาย, จัดการบทความ (Articles), และควบคุมการตั้งค่าหน้าร้าน +* **เป้าหมาย:** เพื่อให้ผู้ดูแลระบบสามารถบริหารจัดการร้านค้าได้อย่างมีประสิทธิภาพผ่าน Interface ที่ใช้งานง่าย +* **การเชื่อมโยง:** เชื่อมต่อกับ `backend` ผ่าน API เพื่อดึงและอัปเดตข้อมูลต่างๆ + +## 3. Main Website (`apps/ssr`) +**เว็บไซต์หลัก (Public Interface)** + +* **เทคโนโลยีที่ใช้:** Next.js (React), Tailwind CSS +* **การทำงาน:** เป็นส่วนการแสดงผลที่ใช้ Server-Side Rendering (SSR) เพื่อให้รองรับการทำ SEO (Search Engine Optimization) ได้ดีที่สุด โดยจะเน้นการแสดงสินค้าและบทความให้แก่บุคคลทั่วไป +* **เป้าหมาย:** เพื่อสร้างการรับรู้แบรนด์ (Brand Awareness) และช่วยให้ลูกค้าค้นหาพบผ่าน Search Engine ได้ง่าย +* **การเชื่อมโยง:** เชื่อมต่อกับ `backend` เพื่อดึงข้อมูลสินค้าและเนื้อหามาแสดงผลบนหน้าเว็บ + +## 4. LIFF Mobile App (`apps/website`) +**หน้ากากมือถือสำหรับลูกค้า (Customer Interface)** + +* **เทคโนโลยีที่ใช้:** React, Vite, LINE Front-end Framework (LIFF), Tailwind CSS +* **การทำงาน:** เป็น Single Page Application (SPA) ที่ออกแบบมาเพื่อรันภายในแอปพลิเคชัน LINE โดยเฉพาะ ลูกค้าสามารถสั่งซื้อสินค้า, ติดตามสถานะคำสั่งซื้อ, และรับสิทธิพิเศษต่างๆ ได้ทันทีผ่าน LINE OA +* **เป้าหมาย:** เพื่อความสะดวกสบายของลูกค้าในการเข้าถึงร้านค้าผ่านช่องทางที่คุ้นเคย (LINE) และสร้างความผูกพันกับลูกค้า (Customer Loyalty) +* **การเชื่อมโยง:** เชื่อมต่อกับ LINE Platform ผ่าน LIFF API และเชื่อมต่อกับ `backend` เพื่อทำรายการสั่งซื้อและจัดการข้อมูลสมาชิก + +--- + +## สรุปการเชื่อมโยงของระบบ + +```mermaid +graph TD + subgraph "External Platforms" + PostgreSQL[(Database)] + LINE[LINE Platform] + end + + subgraph "Internal Apps" + B[Backend - ElysiaJS] + A[Admin - React/Vite] + S[Main Website - Next.js] + W[LIFF App - React/Vite] + end + + A -- API Calls --> B + S -- API Calls --> B + W -- API Calls --> B + W -- Integration --> LINE + B -- Query/Save --> PostgreSQL +``` + +* **Backend** คือหัวใจหลักที่ทุกส่วนต้องวิ่งมาหา +* **Admin** จัดการข้อมูลทั้งหมดผ่าน Backend +* **Main Website (SSR)** ดึงข้อมูลไปแสดงเพื่อผลทางด้าน Google Search/SEO +* **LIFF App** เป็นช่องทางหลักที่ลูกค้าใช้สื่อสารและสั่งซื้อสินค้าผ่านแอป LINE + +--- + +## 5. การ Deployment และ CI/CD + +โปรเจกต์นี้ใช้ระบบ **Git Hooks (post-receive)** ในการทำ Automated Deployment เมื่อมีการ Push โค้ดไปยังเซิร์ฟเวอร์ + +### Git Remote Configuration +คุณสามารถส่งโค้ดขึ้นเซิร์ฟเวอร์เพื่อ Deploy ได้ผ่านคำสั่ง: +```bash +git push server master +``` +* **Remote Name:** `server` +* **Remote URL:** `ti@kitizone.com:/home/ti/git/irelax.git` + +### ขั้นตอนการทำงานของสคริปต์ Deployment (`post-receive`) +เมื่อมีการ Push โค้ด สคริปต์จะทำงานโดยอัตโนมัติดังนี้: + +1. **Checkout & Build**: ดึงโค้ดล่าสุดมายังโฟลเดอร์ชั่วคราวและรัน `bun install` +2. **Frontend Deployment**: + * Build `website` และ `admin` (Vite) + * Deploy ไฟล์ `dist` ไปยัง Nginx paths (`/home/ti/projects/MyNginxMain/nginx/html/irelaxaroma.shop/`) +3. **Backend Deployment**: + * Sync โค้ดไปยัง `/home/ti/projects/irelax-api` + * รัน `prisma generate` และ `prisma migrate deploy` เพื่ออัปเดตฐานข้อมูล + * Restart บริการด้วย **PM2** (ชื่อ process: `irelax-backend`) +4. **SSR Deployment**: + * Build Next.js app และ Sync ไปยัง `/home/ti/projects/irelax-ssr` + * Restart บริการด้วย **PM2** (ชื่อ process: `irelax-ssr`) บนพอร์ต `3001` +5. **Cleanup**: ลบไฟล์ชั่วคราวที่ใช้ในการ Build ออก + +### สรุป URL การเข้าใช้งาน (Production) +* **Website (SPA):** [https://irelaxaroma.shop](https://irelaxaroma.shop) +* **Admin Panel:** [https://admin.irelaxaroma.shop](https://admin.irelaxaroma.shop) +* **API Backend:** [https://api.irelaxaroma.shop](https://api.irelaxaroma.shop) +* **SSR Next.js:** [https://ssr.irelaxaroma.shop](https://ssr.irelaxaroma.shop) + +--- + +## 6. ข้อควรปฏิบัติในการอัปเดตฐานข้อมูล (Prisma) + +เพื่อให้ระบบ Deployment อัปเดตฐานข้อมูลบนเซิร์ฟเวอร์ได้อย่างถูกต้องและปลอดภัย ควรปฏิบัติตามขั้นตอนนี้: + +1. **ที่เครื่องคอมพิวเตอร์ของคุณ (Local):** + * ทำการแก้ไขไฟล์ `apps/backend/prisma/schema.prisma` ตามต้องการ + * รันคำสั่งเพื่อสร้างไฟล์ Migration ใหม่: + ```bash + npx prisma migrate dev --name <ระบุชื่อการเปลี่ยนแปลง> + ``` + * Prisma จะสร้างไฟล์ในโฟลเดอร์ `apps/backend/prisma/migrations/` +2. **การ Commit โค้ด:** + * ต้องทำการ Commit ทั้งไฟล์ `schema.prisma` และ **โฟลเดอร์ migrations** ที่เกิดขึ้นใหม่เข้าไปใน Git ด้วยทุกครั้ง +3. **การ Deploy:** + * เมื่อสั่ง `git push server master` ระบบ Git Hook บนเซิร์ฟเวอร์จะตรวจพบไฟล์ Migration ใหม่ + * สคริปต์จะรัน `prisma migrate deploy` ให้โดยอัตโนมัติ เพื่อนำความเปลี่ยนแปลงเข้าสู่ฐานข้อมูล Production โดยไม่ทำให้ข้อมูลเดิมสูญหาย diff --git a/Text.txt b/Text.txt new file mode 100644 index 0000000..cedc0ee --- /dev/null +++ b/Text.txt @@ -0,0 +1,89 @@ +ตะไคร้,120mlSeller SKU: 120-ตะไคร้-120ml +ลาเวนเดอร์,120mlSeller SKU: 120-ลาเวนเดอร์-120ml +ลาเวนเดอร์,30mlSeller SKU: 30-ลาเวนเดอร์-30ml +วานิลลา,120mlSeller SKU: 120-วานิลลา-120ml +วานิลลา,30mlSeller SKU: 30-วานิลลา-30ml +ซากุระ,120mlSeller SKU: 120-ซากุระ-120ml +ซากุระ,30mlSeller SKU: 30-ซากุระ-30ml +มะลิ,120mlSeller SKU: 120-มะลิ-120ml +มะลิ,30mlSeller SKU: 30-มะลิ-30ml +ยูคาลิปตัส,120mlSeller SKU: 120-ยูคาลิปตัส-120ml +ยูคาลิปตัส,30mlSeller SKU: 30-ยูคาลิปตัส-30ml +เฟรชลาเวนเดอร์,120mlSeller SKU: 120-เฟรชลาเวนเดอร์-120ml +เฟรชลาเวนเดอร์,30mlSeller SKU: 30-เฟรชลาเวนเดอร์-30ml +เปเปอร์มิ้น,120mlSeller SKU: 120-เปเปอร์มิ้น-120ml +เปเปอร์มิ้น,30mlSeller SKU: 30-เปเปอร์มิ้น-30ml +โอเชี่ยน,120mlSeller SKU: 120-โอเชี่ยน-120ml +โอเชี่ยน,30mlSeller SKU: 30-โอเชี่ยน-30ml +เซเว่นซี,120mlSeller SKU: 120-เซเว่นซี-120ml +เซเว่นซี,30mlSeller SKU: 30-เซเว่นซี-30ml +ดอกแก้ว,120mlSeller SKU: 120-ดอกแก้ว-120ml +ดอกแก้ว,30mlSeller SKU: 30-ดอกแก้ว-30ml +ดอกโมก,120mlSeller SKU: 120-ดอกโมก-120ml +ดอกโมก,30mlSeller SKU: 30-ดอกโมก-30ml +ลีลาวดี,120mlSeller SKU: 120-ลีลาวดี-120ml +ลีลาวดี,30mlSeller SKU: 30-ลีลาวดี-30ml +ส้ม,120mlSeller SKU: 120-ส้ม-120ml +ส้ม,30mlSeller SKU: 30-ส้ม-30ml +มะนาว,120mlSeller SKU: 120-มะนาว-120ml +มะนาว,30mlSeller SKU: 30-มะนาว-30ml +แคนตาลูป,120mlSeller SKU: 120-แคนตาลูป-120ml +แคนตาลูป,30mlSeller SKU: 30-แคนตาลูป-30ml +แอปเปิ้ลแดง,120mlSeller SKU: 120-แอปเปิ้ลแดง-120ml +แอปเปิ้ลแดง,30mlSeller SKU: 30-แอปเปิ้ลแดง-30ml +แอปเปิ้ลเขียว,120mlSeller SKU: 120-แอปเปิ้ลเขียว-120ml +แอปเปิ้ลเขียว,30mlSeller SKU: 30-แอปเปิ้ลเขียว-30ml +ดอกลิลลี่,120mlSeller SKU: 120-ดอกลิลลี่-120ml +ดอกลิลลี่,30mlSeller SKU: 30-ดอกลิลลี่-30ml +โรสเมรี่,120mlSeller SKU: 120-โรสเมรี่-120ml +โรสเมรี่,30mlSeller SKU: 30-โรสเมรี่-30ml +อัญชัน,120mlSeller SKU: 120-อัญชัน-120ml +อัญชัน,30mlSeller SKU: 30-อัญชัน-30ml +กุหลาบแดง,120mlSeller SKU: 120-กุหลาบแดง-120ml +กุหลาบแดง,30mlSeller SKU: 30-กุหลาบแดง-30ml +หมากฝรั่ง,120mlSeller SKU: 120-หมากฝรั่ง-120ml +หมากฝรั่ง,30mlSeller SKU: 30-หมากฝรั่ง-30ml +ผลไม้รวม,120mlSeller SKU: 120-ผลไม้รวม-120ml +ผลไม้รวม,30mlSeller SKU: 30-ผลไม้รวม-30ml +มะกรูด,120mlSeller SKU: 120-มะกรูด-120ml +มะกรูด,30mlSeller SKU: 30-มะกรูด-30ml +แซนด้าวูส,120mlSeller SKU: 120-แซนด้าวูส-120ml +แซนด้าวูส,30mlSeller SKU: 30-แซนด้าวูส-30ml +ดอกปีบ,120mlSeller SKU: 120-ดอกปีบ-120ml +ดอกปีบ,30mlSeller SKU: 30-ดอกปีบ-30ml +ดอกบัว,120mlSeller SKU: 120-ดอกบัว-120ml +ดอกบัว,30mlSeller SKU: 30-ดอกบัว-30ml +โอโซน,120mlSeller SKU: 120-โอโซน-120ml +โอโซน,30mlSeller SKU: 30-โอโซน-30ml +ไทยสปา,120mlSeller SKU: 120-ไทยสปา-120ml +ไทยสปา,30mlSeller SKU: 30-ไทยสปา-30ml +กล้วย,120mlSeller SKU: 120-กล้วย-120ml +กล้วย,30mlSeller SKU: 30-กล้วย-30ml +กาแฟ,120mlSeller SKU: 120-กาแฟ-120ml +กาแฟ,30mlSeller SKU: 30-กาแฟ-30ml +ดอกชมนาด,120mlSeller SKU: 120-ดอกชมนาด-120ml +ดอกชมนาด,30mlSeller SKU: 30-ดอกชมนาด-30ml +กลิ่นน้ำนม,120mlSeller SKU: 120-กลิ่นน้ำนม-120ml +กลิ่นน้ำนม,30mlSeller SKU: 30-กลิ่นน้ำนม-30ml +ดอกการะเวก,120mlSeller SKU: 120-ดอกการะเวก-120ml +ดอกการะเวก,30mlSeller SKU: 30-ดอกการะเวก-30ml +ดอกจำปา,120mlSeller SKU: 120-ดอกจำปา-120ml +ดอกจำปา,30mlSeller SKU: 30-ดอกจำปา-30ml +ดอกพิกุล,120mlSeller SKU: 120-ดอกพิกุล-120ml +ดอกพิกุล,30mlSeller SKU: 30-ดอกพิกุล-30ml +ดอกพุด,120mlSeller SKU: 120-ดอกพุด-120ml +ดอกพุด,30mlSeller SKU: 30-ดอกพุด-30ml +ดอกราตรี,120mlSeller SKU: 120-ดอกราตรี-120ml +ดอกราตรี,30mlSeller SKU: 30-ดอกราตรี-30ml +ดอกลำดวน,120mlSeller SKU: 120-ดอกลำดวน-120ml +ดอกลำดวน,30mlSeller SKU: 30-ดอกลำดวน-30ml +ทับทิม,120mlSeller SKU: 120-ทับทิม-120ml +ทับทิม,30mlSeller SKU: 30-ทับทิม-30ml +มะละกอสุก,120mlSeller SKU: 120-มะละกอสุก-120ml +มะละกอสุก,30mlSeller SKU: 30-มะละกอสุก-30ml +ดอกราชาวดี,120mlSeller SKU: 120-ดอกราชาวดี-120ml +ดอกราชาวดี,30mlSeller SKU: 30-ดอกราชาวดี-30ml +กลิ่นลุกพีช,120mlSeller SKU: 120-กลิ่นลุกพีช-120ml +กลิ่นลุกพีช,30mlSeller SKU: 30-กลิ่นลุกพีช-30ml +กลิ่นองุ่น,120mlSeller SKU: 120-กลิ่นองุ่น-120ml +กลิ่นองุ่น,30ml \ No newline at end of file diff --git a/apps/admin/.env.production b/apps/admin/.env.production new file mode 100644 index 0000000..b0269fe --- /dev/null +++ b/apps/admin/.env.production @@ -0,0 +1 @@ +VITE_API_URL="https://api.homaroma.shop/api" diff --git a/apps/admin/.gitignore b/apps/admin/.gitignore new file mode 100644 index 0000000..e49999d --- /dev/null +++ b/apps/admin/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +!.env.production diff --git a/apps/admin/README.md b/apps/admin/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/apps/admin/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/admin/eslint.config.js b/apps/admin/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/apps/admin/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..4fe18a5 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + admin + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..b401637 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,40 @@ +{ + "name": "admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@homaroma/types": "workspace:*", + "@tailwindcss/vite": "^4.2.2", + "@types/papaparse": "^5.5.2", + "lucide-react": "^1.7.0", + "papaparse": "^5.5.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-quill-new": "^3.8.3", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1", + "tailwindcss": "^4.2.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/apps/admin/public/favicon.svg b/apps/admin/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/admin/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/public/icons.svg b/apps/admin/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/admin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin/shopee-hom/Order.all.20260401_20260419.xlsx b/apps/admin/shopee-hom/Order.all.20260401_20260419.xlsx new file mode 100644 index 0000000..7b3ef3a Binary files /dev/null and b/apps/admin/shopee-hom/Order.all.20260401_20260419.xlsx differ diff --git a/apps/admin/src/App.css b/apps/admin/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/apps/admin/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..42bd214 --- /dev/null +++ b/apps/admin/src/App.tsx @@ -0,0 +1,75 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import AdminLayout from './components/AdminLayout' +import DashboardPage from './pages/DashboardPage' +import ProductsPage from './pages/ProductsPage' +import ProductEditPage from './pages/ProductEditPage' +import CategoriesPage from './pages/CategoriesPage' +import CustomersPage from './pages/CustomersPage' +import HeroSettingsPage from './pages/HeroSettingsPage' +import RawMaterialsPage from './pages/RawMaterialsPage' +import LoginPage from './pages/LoginPage' +import ProtectedRoute from './components/ProtectedRoute' +import OrdersPage from './pages/OrdersPage' +import OrderDetailPage from './pages/OrderDetailPage' +import SystemSettingsPage from './pages/SystemSettingsPage' +import ArticlesPage from './pages/ArticlesPage' +import ArticleEditPage from './pages/ArticleEditPage' +import CouponsPage from './pages/CouponsPage' +import TiktokPage from './pages/TiktokPage' +import TiktokAnalysisPage from './pages/TiktokAnalysisPage' +import ShopeePage from './pages/ShopeePage' +import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage' +import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage' +import ShopeeOverviewPage from './pages/ShopeeOverviewPage' +import TiktokOrderPage from './pages/TiktokOrderPage' +import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage' +import TiktokOverviewPage from './pages/TiktokOverviewPage' +import LazadaOrderPage from './pages/LazadaOrderPage' +import LazadaAnalyticsPage from './pages/LazadaAnalyticsPage' +import LazadaOverviewPage from './pages/LazadaOverviewPage' +import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage' + +function App() { + return ( + + + {/* Public Route */} + } /> + + {/* Guarded Admin Routes */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} + +export default App diff --git a/apps/admin/src/assets/hero.png b/apps/admin/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/apps/admin/src/assets/hero.png differ diff --git a/apps/admin/src/assets/react.svg b/apps/admin/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/admin/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/admin/src/assets/vite.svg b/apps/admin/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/admin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx new file mode 100644 index 0000000..c4301a4 --- /dev/null +++ b/apps/admin/src/components/AdminLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom' +import Sidebar from './Sidebar' + +export default function AdminLayout() { + return ( +
+ +
+
+ +
+
+
+ ) +} diff --git a/apps/admin/src/components/ProtectedRoute.tsx b/apps/admin/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..d6e00e6 --- /dev/null +++ b/apps/admin/src/components/ProtectedRoute.tsx @@ -0,0 +1,13 @@ +import { Navigate, Outlet } from 'react-router-dom' + +export default function ProtectedRoute() { + const token = localStorage.getItem('irelax_admin_token') + + if (!token) { + // If no token, redirect to login page + return + } + + // If token exists, render the child routes (Outlet) + return +} diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx new file mode 100644 index 0000000..1193983 --- /dev/null +++ b/apps/admin/src/components/Sidebar.tsx @@ -0,0 +1,105 @@ +import { NavLink, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, + Package, + PackageOpen, + Settings, + LogOut, + ChevronRight, + ShoppingCart, + ShoppingBag, + BookOpen, + TrendingUp, + Image, + Tags, + Ticket, + BarChart3, + Layers, + Globe2, + Users +} from 'lucide-react' + +const MENU_ITEMS = [ + { icon: LayoutDashboard, label: 'Dashboard', to: '/' }, + { icon: Tags, label: 'Categories', to: '/categories' }, + { icon: Package, label: 'Products', to: '/products' }, + { icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' }, + { icon: Users, label: 'LINE Customers', to: '/customers' }, + { icon: ShoppingCart, label: 'Orders', to: '/orders' }, + { icon: Ticket, label: 'Coupons', to: '/coupons' }, + { icon: Image, label: 'Hero Section', to: '/hero' }, + { icon: BookOpen, label: 'Scent Guide', to: '/articles' }, + { icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' }, + { icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' }, + { icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' }, + { icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' }, + { icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' }, + { icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' }, + { icon: BarChart3, label: 'TikTok Analytics', to: '/tiktok-analytics' }, + { icon: Layers, label: 'TikTok ทุกร้าน', to: '/tiktok-overview' }, + { icon: ShoppingCart, label: 'Lazada Orders', to: '/lazada-order' }, + { icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' }, + { icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' }, + { icon: Globe2, label: 'All Analytics', to: '/global-analytics' }, + { icon: Settings, label: 'Settings', to: '/settings' }, +] + +export default function Sidebar() { + const navigate = useNavigate() + + const handleLogout = () => { + localStorage.removeItem('irelax_admin_token') + localStorage.removeItem('irelax_admin_user') + navigate('/login') + } + + return ( + + ) +} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css new file mode 100644 index 0000000..fcd258c --- /dev/null +++ b/apps/admin/src/index.css @@ -0,0 +1,112 @@ +@import "tailwindcss"; + +@theme { + /* iRelax Admin - Professional Dark Palette */ + --color-night-950: #050608; + --color-night-900: #0B0C10; + --color-night-800: #1F2833; + --color-night-700: #45A29E; + --color-cyan-glow: #66FCF1; + --color-silver-metallic: #C5C6C7; + --color-slate-dark: #1e293b; +} + +@layer base { + body { + @apply bg-night-950 text-silver-metallic font-sans antialiased; + min-height: 100vh; + } + + h1, h2, h3, h4 { + @apply text-white tracking-tight; + } +} + +@layer components { + .admin-card { + @apply bg-night-900/50 backdrop-blur-md border border-white/5 rounded-xl p-6 transition-all duration-300; + } + + .admin-card-hover { + @apply hover:border-cyan-glow/20 hover:shadow-[0_0_20px_rgba(102,252,241,0.05)]; + } + + .btn-primary { + @apply bg-cyan-glow text-night-900 px-4 py-2 rounded-lg font-bold text-sm tracking-wide uppercase transition-all duration-300 hover:bg-white hover:shadow-[0_0_15px_rgba(102,252,241,0.3)]; + } + + .btn-ghost { + @apply px-4 py-2 rounded-lg text-sm text-slate-400 hover:bg-white/5 hover:text-white transition-all; + } + + .sidebar-link { + @apply flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200; + } + + .sidebar-link-active { + @apply bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20 shadow-[0_0_15px_rgba(102,252,241,0.05)]; + } + + .sidebar-link-inactive { + @apply text-slate-400 hover:bg-white/5 hover:text-white; + } +} + +/* Custom Scrollbar for Admin */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + @apply bg-night-950; +} + +::-webkit-scrollbar-thumb { + @apply bg-night-800 rounded-full; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-night-700; +} + +/* Sidebar Nav — custom thin scrollbar */ +.sidebar-nav::-webkit-scrollbar { + width: 3px; +} + +.sidebar-nav::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-nav::-webkit-scrollbar-thumb { + @apply bg-night-800 rounded-full; +} + +.sidebar-nav::-webkit-scrollbar-thumb:hover { + @apply bg-cyan-glow/40; +} + +/* Firefox */ +.sidebar-nav { + scrollbar-width: thin; + scrollbar-color: #1F2833 transparent; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes zoomIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.animate-fade-in { + animation: fadeIn 0.4s ease-out forwards; +} + +.animate-zoom-in { + animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/admin/src/pages/ArticleEditPage.tsx b/apps/admin/src/pages/ArticleEditPage.tsx new file mode 100644 index 0000000..6631628 --- /dev/null +++ b/apps/admin/src/pages/ArticleEditPage.tsx @@ -0,0 +1,241 @@ +import { useState, useEffect, useMemo, useRef } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { API_BASE } from '@irelax/types' +import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react' +import ReactQuill from 'react-quill-new' +import 'react-quill-new/dist/quill.snow.css' + +export default function ArticleEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = id === 'new' + const navigate = useNavigate() + const quillRef = useRef(null) + + const [loading, setLoading] = useState(!isNew) + const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) + + const [title, setTitle] = useState('') + const [slug, setSlug] = useState('') + const [coverImage, setCoverImage] = useState('') + const [content, setContent] = useState('') + const [isPublished, setIsPublished] = useState(false) + + useEffect(() => { + if (isNew) return + fetch(`${API_BASE}/articles/id/${id}`) + .then(res => res.json()) + .then(data => { + setTitle(data.title) + setSlug(data.slug) + setCoverImage(data.coverImage || '') + setContent(data.content) + setIsPublished(data.isPublished) + }) + .catch(console.error) + .finally(() => setLoading(false)) + }, [id, isNew]) + + // Automatically generate slug from title if it's new + useEffect(() => { + if (isNew && title && !slug.includes('-')) { + setSlug(title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '')) + } + }, [title, isNew]) + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData }) + const data = await res.json() + if (data.url) setCoverImage(data.url) + } catch (err) { + console.error(err) + alert('Upload failed') + } finally { + setUploading(false) + } + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + try { + const payload = { title, slug, coverImage, content, isPublished } + const res = await fetch(`${API_BASE}/articles${isNew ? '' : `/${id}`}`, { + method: isNew ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + if (!res.ok) throw new Error(await res.text()) + navigate('/articles') + } catch (err) { + console.error(err) + alert('Failed to save article. Make sure the slug is unique.') + } finally { + setSaving(false) + } + } + + const imageHandler = () => { + const url = prompt('Please enter the Image URL: \n(Or leave blank and click OK to upload from your computer)') + + if (url) { + const quill = quillRef.current?.getEditor() + if (quill) { + const range = quill.getSelection(true) + quill.insertEmbed(range?.index || 0, 'image', url) + } + } else if (url === "") { + const input = document.createElement('input') + input.setAttribute('type', 'file') + input.setAttribute('accept', 'image/*') + input.click() + + input.onchange = () => { + const file = input.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const quill = quillRef.current?.getEditor() + if (quill) { + const range = quill.getSelection(true) + quill.insertEmbed(range?.index || 0, 'image', e.target?.result) + } + } + reader.readAsDataURL(file) + } + } + } + } + + const modules = useMemo(() => ({ + toolbar: { + container: [ + [{ 'header': [1, 2, 3, false] }], + ['bold', 'italic', 'underline', 'strike', 'blockquote'], + [{'list': 'ordered'}, {'list': 'bullet'}], + ['link', 'image'], + ['clean'] + ], + handlers: { + image: imageHandler + } + } + }), []) + + if (loading) return
+ + return ( +
+
+ + + +
+

{isNew ? 'New Article' : 'Edit Article'}

+

Write engaging guides for your audience.

+
+
+ +
+
+
+
+ + setTitle(e.target.value)} + className="admin-input w-full text-lg font-bold" + placeholder="e.g. 5 Benefits of Lavender Oil" + /> +
+
+ + setSlug(e.target.value)} + className="admin-input w-full font-mono text-sm text-cyan-glow" + placeholder="e.g. benefits-of-lavender" + /> +
+
+ +
+ +
+ {coverImage ? ( + Cover + ) : ( +
+ {uploading ? : } +

Upload Cover

+
+ )} + +
+
+
+ +
+ +
+ +
+ +
+ +
+ + Cancel + +
+ + {/* Mobile Actions */} +
+ + +
+
+
+ ) +} diff --git a/apps/admin/src/pages/ArticlesPage.tsx b/apps/admin/src/pages/ArticlesPage.tsx new file mode 100644 index 0000000..fc75a1d --- /dev/null +++ b/apps/admin/src/pages/ArticlesPage.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { API_BASE, type Article } from '@irelax/types' +import { Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react' + +export default function ArticlesPage() { + const [articles, setArticles] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchArticles = async () => { + try { + const res = await fetch(`${API_BASE}/articles`) + const data = await res.json() + setArticles(data) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchArticles() + }, []) + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this article?')) return + try { + const res = await fetch(`${API_BASE}/articles/${id}`, { method: 'DELETE' }) + if (res.ok) { + setArticles(prev => prev.filter(a => a.id !== id)) + } + } catch (err) { + console.error(err) + alert('Failed to delete') + } + } + + return ( +
+
+
+

Articles / Scent Guide

+

Manage educational blog posts and guides.

+
+ + + Create Article + +
+ +
+
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + )) + ) : articles.length === 0 ? ( + + ) : ( + articles.map(article => ( + + + + + + + + )) + )} + +
ImageTitle & SlugStatusDateActions
No articles found. Create your first guide!
+ {article.coverImage ? ( +
+ {article.title} +
+ ) : ( +
+ +
+ )} +
+

{article.title}

+

/{article.slug}

+
+ {article.isPublished ? ( + + Published + + ) : ( + + Draft + + )} + + {new Date(article.createdAt).toLocaleDateString()} + +
+ + + + +
+
+
+
+
+ ) +} diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..c9c5400 --- /dev/null +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -0,0 +1,295 @@ +import { useState, useEffect } from 'react' +import { API_BASE, getCategoryImage, type Category } from '@irelax/types' +import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react' + +export default function CategoriesPage() { + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [editingCategory, setEditingCategory] = useState | null>(null) + + const fetchData = async () => { + setLoading(true) + try { + const r = await fetch(`${API_BASE}/categories`) + const data = await r.json() + setCategories(data) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!editingCategory) return + + try { + const url = editingCategory.id + ? `${API_BASE}/categories/${editingCategory.id}` + : `${API_BASE}/categories` + const method = editingCategory.id ? 'PUT' : 'POST' + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: editingCategory.name, + imageUrl: editingCategory.imageUrl || null + }) + }) + + if (res.ok) { + setIsModalOpen(false) + fetchData() + } else { + const errorData = await res.json() + alert(errorData.message || 'Failed to save category') + } + } catch (err) { + console.error('Save error:', err) + alert('An error occurred while saving.') + } + } + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this category?')) return + try { + const res = await fetch(`${API_BASE}/categories/${id}`, { + method: 'DELETE' + }) + if (res.ok) { + fetchData() + } else { + const errorData = await res.json() + alert(errorData.message || 'Failed to delete category') + } + } catch (err) { + console.error('Delete error:', err) + } + } + + const handleUpload = async (file: File) => { + setIsUploading(true) + const formData = new FormData() + formData.append('file', file) + + try { + const res = await fetch(`${API_BASE}/upload`, { + method: 'POST', + body: formData + }) + const data = await res.json() + if (data.url) { + setEditingCategory(prev => ({ ...prev, imageUrl: data.url })) + } + } catch (err) { + console.error('Upload error:', err) + } finally { + setIsUploading(false) + } + } + + const handlePasteFromClipboard = async () => { + try { + const items = await navigator.clipboard.read() + for (const item of items) { + const imageTypes = item.types.filter(t => t.startsWith('image/')) + for (const type of imageTypes) { + const blob = await item.getType(type) + const file = new File([blob], 'pasted-category-image.png', { type }) + handleUpload(file) + return + } + } + alert('No image found in clipboard') + } catch (err) { + console.error('Clipboard error:', err) + alert('Please allow clipboard access or copy an image first.') + } + } + + const openAddModal = () => { + setEditingCategory({ + name: '', + imageUrl: '' + }) + setIsModalOpen(true) + } + + const openEditModal = (cat: Category) => { + setEditingCategory(cat) + setIsModalOpen(true) + } + + return ( +
+ {!isModalOpen ? ( + <> +
+
+

Category Management

+

Managing scent classification and product groups.

+
+ +
+ +
+ {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+ )) + ) : ( + categories.map((cat) => ( +
+
+
+ {cat.name} +
+
+ + +
+
+ +
+

{cat.name}

+
+ + {cat._count?.products || 0} Products +
+
+ +
+
+ )) + )} +
+ + ) : ( + editingCategory && ( +
+
+

+ + {editingCategory.id ? 'Edit Category' : 'Create Category'} +

+ +
+ +
+
+ + {/* Image Upload Area */} +
+
+ + +
+
document.getElementById('category-upload')?.click()} + > + {editingCategory.imageUrl ? ( + <> + Category +
+ +
+ + ) : ( +
+ +

Select Image

+
+ )} + + {isUploading && ( +
+ +
+ )} + { + const file = e.target.files?.[0] + if (file) handleUpload(file) + }} + /> +
+
+ +
+ + setEditingCategory({ ...editingCategory, name: e.target.value })} + className="admin-input w-full" + placeholder="e.g. Reed Diffuser" + /> +
+
+ +
+ + +
+
+
+ ) + )} +
+ ) +} diff --git a/apps/admin/src/pages/CouponsPage.tsx b/apps/admin/src/pages/CouponsPage.tsx new file mode 100644 index 0000000..5dec67b --- /dev/null +++ b/apps/admin/src/pages/CouponsPage.tsx @@ -0,0 +1,351 @@ +import { useState, useEffect } from 'react' +import { API_BASE } from '@irelax/types' +import { Edit3, Ticket, Trash2, Plus, X, Power, PowerOff } from 'lucide-react' + +interface Coupon { + id: number + code: string + type: string + value: number + minPurchase: number + maxDiscount: number | null + usageLimit: number | null + usedCount: number + expiresAt: string | null + isActive: boolean +} + +export default function CouponsPage() { + const [coupons, setCoupons] = useState([]) + const [loading, setLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingCoupon, setEditingCoupon] = useState | null>(null) + + const fetchData = async () => { + setLoading(true) + try { + const r = await fetch(`${API_BASE}/coupons`) + const data = await r.json() + setCoupons(data) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!editingCoupon) return + + try { + const url = editingCoupon.id + ? `${API_BASE}/coupons/${editingCoupon.id}` + : `${API_BASE}/coupons` + const method = editingCoupon.id ? 'PATCH' : 'POST' + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: editingCoupon.code, + type: editingCoupon.type, + value: Number(editingCoupon.value), + minPurchase: Number(editingCoupon.minPurchase || 0), + maxDiscount: editingCoupon.maxDiscount ? Number(editingCoupon.maxDiscount) : null, + usageLimit: editingCoupon.usageLimit ? Number(editingCoupon.usageLimit) : null, + expiresAt: editingCoupon.expiresAt || null, + isActive: editingCoupon.isActive !== undefined ? editingCoupon.isActive : true + }) + }) + + if (res.ok) { + setIsModalOpen(false) + fetchData() + } else { + const errorData = await res.json() + alert(errorData.error || 'Failed to save coupon') + } + } catch (err) { + console.error('Save error:', err) + alert('An error occurred while saving.') + } + } + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this coupon?')) return + try { + const res = await fetch(`${API_BASE}/coupons/${id}`, { + method: 'DELETE' + }) + if (res.ok) { + fetchData() + } else { + const errorData = await res.json() + alert(errorData.error || 'Failed to delete coupon') + } + } catch (err) { + console.error('Delete error:', err) + } + } + + const handleToggleActive = async (id: number, currentStatus: boolean) => { + try { + const res = await fetch(`${API_BASE}/coupons/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !currentStatus }) + }) + if (res.ok) { + fetchData() + } + } catch (err) { + console.error('Toggle error:', err) + } + } + + const openAddModal = () => { + setEditingCoupon({ + code: '', + type: 'PERCENTAGE', + value: 0, + minPurchase: 0, + maxDiscount: null, + usageLimit: null, + expiresAt: '', + isActive: true + }) + setIsModalOpen(true) + } + + const openEditModal = (coupon: Coupon) => { + setEditingCoupon({ + ...coupon, + expiresAt: coupon.expiresAt ? coupon.expiresAt.split('T')[0] : '' + }) + setIsModalOpen(true) + } + + const formatDiscount = (type: string, value: number) => { + if (type === 'PERCENTAGE') return `${value}% OFF` + if (type === 'FIXED_AMOUNT') return `฿${value} OFF` + if (type === 'FREE_SHIPPING') return `FREE SHIPPING` + return `${value}` + } + + return ( +
+ {!isModalOpen ? ( + <> +
+
+

Coupons

+

Manage discount codes and promotional campaigns.

+
+ +
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ )) + ) : ( + coupons.map((coupon) => ( +
+
+
+
+ +
+
+

{coupon.code}

+ + {formatDiscount(coupon.type, coupon.value)} + +
+
+
+ + + +
+
+ +
+
+

Usage

+

{coupon.usedCount} / {coupon.usageLimit ? coupon.usageLimit : '∞'}

+
+
+

Min Purchase

+

฿{coupon.minPurchase}

+
+
+

Expires

+

{coupon.expiresAt ? new Date(coupon.expiresAt).toLocaleDateString() : 'No expiration'}

+
+
+ + {coupon.isActive &&
} +
+ )) + )} +
+ + ) : ( + editingCoupon && ( +
+
+

+ + {editingCoupon.id ? 'Edit Coupon' : 'Create Coupon'} +

+ +
+ +
+
+ +
+ + setEditingCoupon({ ...editingCoupon, code: e.target.value.toUpperCase() })} + className="admin-input w-full uppercase tracking-widest font-bold" + placeholder="e.g. SUMMER2026" + /> +
+ +
+ + +
+ +
+ + setEditingCoupon({ ...editingCoupon, value: parseFloat(e.target.value) })} + className="admin-input w-full disabled:opacity-50" + /> +
+ +
+ + setEditingCoupon({ ...editingCoupon, minPurchase: parseFloat(e.target.value) })} + className="admin-input w-full" + /> +
+ +
+ + setEditingCoupon({ ...editingCoupon, maxDiscount: e.target.value ? parseFloat(e.target.value) : null })} + className="admin-input w-full disabled:opacity-50" + placeholder="Leave empty for no limit" + /> +
+ +
+ + setEditingCoupon({ ...editingCoupon, usageLimit: e.target.value ? parseInt(e.target.value) : null })} + className="admin-input w-full" + placeholder="Leave empty for unlimited" + /> +
+ +
+ + setEditingCoupon({ ...editingCoupon, expiresAt: e.target.value })} + className="admin-input w-full" + /> +
+ +
+ +
+ + +
+
+
+ ) + )} +
+ ) +} diff --git a/apps/admin/src/pages/CustomersPage.tsx b/apps/admin/src/pages/CustomersPage.tsx new file mode 100644 index 0000000..aceb918 --- /dev/null +++ b/apps/admin/src/pages/CustomersPage.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react' +import { API_BASE } from '@irelax/types' + +interface Customer { + id: number + lineUserId: string + displayName: string | null + pictureUrl: string | null + email: string | null + phone: string | null + createdAt: string + _count?: { + orders: number + } +} + +export default function CustomersPage() { + const [customers, setCustomers] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchCustomers() + }, []) + + const fetchCustomers = async () => { + try { + const res = await fetch(`${API_BASE}/customers`) + const data = await res.json() + setCustomers(data) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + if (loading) return
Loading Customers...
+ + return ( +
+
+

LINE Customers

+
+ +
+
+ + + + + + + + + + + {customers.length === 0 ? ( + + + + ) : ( + customers.map(customer => ( + + + + + + + )) + )} + +
CustomerContact InfoOrdersRegistered Date
+ No customers found yet. +
+
+ {customer.displayName +
+

{customer.displayName || 'No Name'}

+

UID: {customer.lineUserId}

+
+
+
+

{customer.phone || '-'}

+

{customer.email || '-'}

+
+ + {customer._count?.orders || 0} Orders + + + {new Date(customer.createdAt).toLocaleDateString('th-TH')} +
+
+
+
+ ) +} diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..0b38ef5 --- /dev/null +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from 'react' +import { Package, Tag, Layers, TrendingUp } from 'lucide-react' +import { API_BASE, type Category, type Product } from '@irelax/types' + +export default function DashboardPage() { + const [stats, setStats] = useState({ + products: 0, + categories: 0, + scents: 0, + avgPrice: 0 + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchData = async () => { + try { + const [catRes, prodRes] = await Promise.all([ + fetch(`${API_BASE}/categories`), + fetch(`${API_BASE}/products`) + ]) + const cats: Category[] = await catRes.json() + const prods: Product[] = await prodRes.json() + + const scents = new Set(prods.map(p => p.scent.id)).size + const totalProducts = prods.length + const avgPrice = prods.reduce((acc, curr) => acc + (curr.price || 0), 0) / (totalProducts || 1) + + setStats({ + products: totalProducts, + categories: cats.length, + scents, + avgPrice + }) + } catch (err) { + console.error('Dashboard data error:', err) + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + const cards = [ + { label: 'Total Products', value: stats.products, icon: Package, color: 'text-blue-400' }, + { label: 'Active Categories', value: stats.categories, icon: Layers, color: 'text-cyan-glow' }, + { label: 'Unique Scents', value: stats.scents, icon: Tag, color: 'text-purple-400' }, + { label: 'Avg Unit Price', value: `฿${Math.round(stats.avgPrice).toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' }, + ] + + return ( +
+
+
+

System Overview

+

Real-time stats from the iRelax platform.

+
+
+

Last updated

+

{new Date().toLocaleString()}

+
+
+ +
+ {cards.map((card) => ( +
+
+
+ +
+ Stats +
+
+ {loading ? ( +
+ ) : ( +

{card.value}

+ )} +

{card.label}

+
+
+ ))} +
+ + {/* Placeholder for recent activities */} +
+
+

Inventory Summary

+
+ {/* Dummy chart-like bars */} + {[ + { label: 'Oil Ozone', val: 0.8 }, + { label: 'Aroma Oil', val: 0.6 }, + { label: 'Reed Diffuser', val: 0.4 }, + { label: 'Car Diffuser', val: 0.2 }, + ].map(i => ( +
+
+ {i.label} + {Math.round(i.val * 100)}% Stock +
+
+
+
+
+ ))} +
+
+ +
+

System Health

+
+ {[ + { label: 'API Backend', status: 'Healthy', time: '12ms' }, + { label: 'Database Service', status: 'Healthy', time: '4ms' }, + { label: 'Admin App Bundle', status: 'Optimized', time: '128kb' }, + ].map(i => ( +
+
+

{i.label}

+

{i.status}

+
+
+

Performance

+

{i.time}

+
+
+ ))} +
+
+
+
+ ) +} diff --git a/apps/admin/src/pages/GlobalAnalyticsPage.tsx b/apps/admin/src/pages/GlobalAnalyticsPage.tsx new file mode 100644 index 0000000..97a1dd4 --- /dev/null +++ b/apps/admin/src/pages/GlobalAnalyticsPage.tsx @@ -0,0 +1,390 @@ +import { useState, useEffect } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, + PieChart, Pie, Cell, Legend, +} from 'recharts'; +import { + TrendingUp, DollarSign, Package, BadgePercent, ShoppingCart, Loader2, Store, Calendar, Globe2 +} from 'lucide-react'; + +const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); + +// We adjust TikTok to Cyan/Pink mix or just Cyan, Lazada to Blue, Shopee to Orange. +const COLORS = { + Shopee: '#f97316', + TikTok: '#06b6d4', + Lazada: '#3b82f6', +}; + +// ── Date range helpers ──────────────────────────────────────────────────────── +type Preset = '7d' | '30d' | '90d' | 'thisMonth' | 'lastMonth' | 'year' | 'lastYear' | 'last12m' | 'all'; +function toISO(d: Date) { return d.toISOString().slice(0, 10); } +function getPresetRange(preset: Preset): { from: string; to: string } { + const now = new Date(); + const today = toISO(now); + if (preset === '7d') { const f = new Date(now); f.setDate(f.getDate() - 6); return { from: toISO(f), to: today }; } + if (preset === '30d') { const f = new Date(now); f.setDate(f.getDate() - 29); return { from: toISO(f), to: today }; } + if (preset === '90d') { const f = new Date(now); f.setDate(f.getDate() - 89); return { from: toISO(f), to: today }; } + if (preset === 'thisMonth') { const f = new Date(now.getFullYear(), now.getMonth(), 1); return { from: toISO(f), to: today }; } + if (preset === 'lastMonth') { const f = new Date(now.getFullYear(), now.getMonth() - 1, 1); const t = new Date(now.getFullYear(), now.getMonth(), 0); return { from: toISO(f), to: toISO(t) }; } + if (preset === 'year') { const f = new Date(now.getFullYear(), 0, 1); return { from: toISO(f), to: today }; } + if (preset === 'lastYear') { const f = new Date(now.getFullYear() - 1, 0, 1); const t = new Date(now.getFullYear() - 1, 11, 31); return { from: toISO(f), to: toISO(t) }; } + if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } + return { from: '', to: '' }; +} +const PRESETS: { key: Preset; label: string }[] = [ + { key: '7d', label: '7 วัน' }, + { key: '30d', label: '30 วัน' }, + { key: '90d', label: '90 วัน' }, + { key: 'thisMonth', label: 'เดือนนี้' }, + { key: 'lastMonth', label: 'เดือนที่แล้ว' }, + { key: 'year', label: 'ปีนี้' }, + { key: 'lastYear', label: 'ปีที่แล้ว' }, + { key: 'last12m', label: '12 เดือนที่ผ่านมา' }, + { key: 'all', label: 'ทั้งหมด' }, +]; + +function authHeaders() { + return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('irelax_admin_token')}` }; +} +function fmt(val: number, dec = 0) { + return new Intl.NumberFormat('th-TH', { minimumFractionDigits: dec, maximumFractionDigits: dec }).format(val); +} +function fmtB(val: number) { return `฿${fmt(val, 2)}`; } + +function KPICard({ label, value, sub, color = 'text-white', icon: Icon }: { label: string; value: string; sub?: string; color?: string; icon: any }) { + return ( +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null; + return ( +
+ {label &&

{label}

} + {payload.map((p: any, i: number) => ( +
+
+ {p.name}: + + {typeof p.value === 'number' ? (p.value > 1000 ? fmtB(p.value) : fmt(p.value)) : p.value} + +
+ ))} +
+ ); +}; + +export default function GlobalAnalyticsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Date range filter + const [preset, setPreset] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const loadData = async (from: string, to: string) => { + setLoading(true); setError(null); setData(null); + const params = new URLSearchParams(); + if (from) params.set('dateFrom', from); + if (to) params.set('dateTo', to); + const qs = params.toString() ? `?${params}` : ''; + + try { + const [shopeeRes, tiktokRes, lazadaRes] = await Promise.all([ + fetch(`${API_URL}/api/shopee/analytics-overview${qs}`, { headers: authHeaders() }).then(r => r.ok ? r.json() : null), + fetch(`${API_URL}/api/tiktok-order/analytics-overview${qs}`, { headers: authHeaders() }).then(r => r.ok ? r.json() : null), + fetch(`${API_URL}/api/lazada-order/analytics-overview${qs}`, { headers: authHeaders() }).then(r => r.ok ? r.json() : null), + ]); + + const shopeeData = shopeeRes?.totals || shopeeRes?.summary || {}; + const shopee = { + totalRevenue: shopeeData.totalRevenue || 0, + totalOrders: shopeeData.totalOrders || 0, + cancelledOrders: shopeeData.totalCancelled || shopeeData.cancelledOrders || 0, + completedOrders: shopeeData.completedOrders || ((shopeeData.totalOrders || 0) - (shopeeData.totalCancelled || 0)), + totalShipping: shopeeData.totalShipping || 0, + }; + + const tiktok = tiktokRes?.summary || { totalRevenue: 0, totalOrders: 0, completedOrders: 0, cancelledOrders: 0, cancelRate: 0, totalShipping: 0 }; + const lazada = lazadaRes?.summary || { totalRevenue: 0, totalOrders: 0, completedOrders: 0, cancelledOrders: 0, cancelRate: 0, totalShipping: 0 }; + + const totalRevenue = shopee.totalRevenue + tiktok.totalRevenue + lazada.totalRevenue; + const totalOrders = shopee.totalOrders + tiktok.totalOrders + lazada.totalOrders; + const completedOrders = shopee.completedOrders + tiktok.completedOrders + lazada.completedOrders; + const cancelledOrders = shopee.cancelledOrders + tiktok.cancelledOrders + lazada.cancelledOrders; + const totalShipping = shopee.totalShipping + tiktok.totalShipping + lazada.totalShipping; + + const platforms = [ + { name: 'Shopee', revenue: shopee.totalRevenue, orders: shopee.totalOrders }, + { name: 'TikTok', revenue: tiktok.totalRevenue, orders: tiktok.totalOrders }, + { name: 'Lazada', revenue: lazada.totalRevenue, orders: lazada.totalOrders }, + ].sort((a, b) => b.revenue - a.revenue); + + // Merge monthly + const monthMap = new Map(); + const addMonthly = (arr: any[], platform: string) => { + if (!arr) return; + arr.forEach(m => { + if (!monthMap.has(m.month)) monthMap.set(m.month, { month: m.month, Shopee: 0, TikTok: 0, Lazada: 0 }); + const rev = m.revenue ?? m.totalRevenue ?? 0; + monthMap.get(m.month)[platform] += rev; + }); + }; + addMonthly(shopeeRes?.monthly, 'Shopee'); + addMonthly(tiktokRes?.monthly, 'TikTok'); + addMonthly(lazadaRes?.monthly, 'Lazada'); + + const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); + + setData({ + platforms, + monthly, + summary: { + totalRevenue, totalOrders, completedOrders, cancelledOrders, totalShipping, + cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0, + aov: completedOrders > 0 ? totalRevenue / completedOrders : 0, + } + }); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadData('', ''); }, []); + + const applyPreset = (p: Preset) => { + setPreset(p); + const range = getPresetRange(p); + setDateFrom(range.from); + setDateTo(range.to); + loadData(range.from, range.to); + }; + + const applyCustom = () => { + setPreset('all'); + loadData(dateFrom, dateTo); + }; + + if (loading) { + return ( +
+ + กำลังรวมข้อมูลระดับ Global (ทุกแพลตฟอร์ม)... +
+ ); + } + + if (error || !data) { + return ( +
+ +

{error || 'เกิดข้อผิดพลาดในการโหลดข้อมูล'}

+
+ ); + } + + const { platforms, monthly, summary } = data; + const pieData = platforms.filter((p: any) => p.revenue > 0).map((p: any) => ({ name: p.name, value: p.revenue })); + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

Global Overview

+

ภาพรวมทุกแพลตฟอร์ม

+
+
+
+ + {/* ── Date Range Filter Bar ─────────────────────────────────────────── */} +
+
+ + {PRESETS.map(p => ( + + ))} +
+ +
+ +
+ กำหนดเอง: + setDateFrom(e.target.value)} + className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none" + /> + ถึง + setDateTo(e.target.value)} + className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none" + /> + +
+ + {(dateFrom || dateTo) && ( +
+ + {dateFrom || '…'} → {dateTo || '…'} + + +
+ )} +
+ + {/* Grand KPI Row */} +
+ + + + +
+ + {/* Revenue Share Pie + Comparison table */} +
+ + {/* Pie Chart */} +
+
+ +

สัดส่วนยอดขายตามแพลตฟอร์ม

+
+
+
+ + + + {pieData.map((entry: any, i: number) => ( + + ))} + + fmtB(v)} /> + + +
+
+ {platforms.map((p: any) => { + if(p.revenue === 0) return null; + const c = COLORS[p.name as keyof typeof COLORS]; + const pct = summary.totalRevenue > 0 ? (p.revenue / summary.totalRevenue) * 100 : 0; + return ( +
+
+ {p.name} + {fmt(pct, 1)}% +
+ ); + })} +
+
+
+ + {/* Comparison Table */} +
+
+ +

เปรียบเทียบแพลตฟอร์ม

+
+
+ + + + + + + + + + + {platforms.map((p: any) => { + const c = COLORS[p.name as keyof typeof COLORS]; + const pct = summary.totalRevenue > 0 ? (p.revenue / summary.totalRevenue) * 100 : 0; + return ( + + + + + + + ); + })} + +
แพลตฟอร์มยอดขายสัดส่วนออเดอร์
+
+
+ {p.name} +
+
{fmtB(p.revenue)}{fmt(pct, 1)}%{fmt(p.orders)}
+
+
+
+ + {/* Monthly Stacked Revenue Chart */} +
+
+ +

ยอดขายรายเดือน (แยกตามแพลตฟอร์ม)

+
+ {monthly.length > 0 ? ( +
+ + + + + v >= 1000 ? `฿${(v / 1000).toFixed(0)}k` : `฿${v}`} /> + } cursor={{ fill: '#ffffff06' }} /> + + {platforms.map((p: any, i: number) => { + const isTop = i === platforms.length - 1; // Simplification + return ( + + ); + })} + + +
+ ) : ( +

ยังไม่มีข้อมูลรายเดือน

+ )} +
+ +
+ ); +} diff --git a/apps/admin/src/pages/HeroSettingsPage.tsx b/apps/admin/src/pages/HeroSettingsPage.tsx new file mode 100644 index 0000000..b1988de --- /dev/null +++ b/apps/admin/src/pages/HeroSettingsPage.tsx @@ -0,0 +1,436 @@ +import { useState, useEffect } from 'react' +import { + LayoutDashboard, + Plus, + Trash2, + CheckCircle2, + Circle, + Image as ImageIcon, + UploadCloud, + Loader2, + X, + ExternalLink, + ChevronRight, + Info, + ClipboardPaste +} from 'lucide-react' +import { API_BASE, type Hero } from '@irelax/types' + +export default function HeroSettingsPage() { + const [heroes, setHeroes] = useState([]) + const [loading, setLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [editingHero, setEditingHero] = useState | null>(null) + + const fetchData = async () => { + setLoading(true) + try { + const res = await fetch(`${API_BASE}/hero/all`) + const data = await res.json() + setHeroes(data) + } catch (err) { + console.error('Fetch error:', err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!editingHero) return + + try { + const res = await fetch(`${API_BASE}/hero`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editingHero) + }) + if (res.ok) { + setIsModalOpen(false) + fetchData() + } + } catch (err) { + console.error('Save error:', err) + } + } + + const handleActivate = async (id: number) => { + try { + const res = await fetch(`${API_BASE}/hero/${id}/activate`, { + method: 'PATCH' + }) + if (res.ok) { + fetchData() + } + } catch (err) { + console.error('Activate error:', err) + } + } + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this Hero entry?')) return + try { + const res = await fetch(`${API_BASE}/hero/${id}`, { + method: 'DELETE' + }) + if (res.ok) { + fetchData() + } + } catch (err) { + console.error('Delete error:', err) + } + } + + const handleUpload = async (file: File) => { + setIsUploading(true) + const formData = new FormData() + formData.append('file', file) + + try { + const res = await fetch(`${API_BASE}/upload`, { + method: 'POST', + body: formData + }) + const data = await res.json() + if (data.url) { + setEditingHero(prev => ({ ...prev, imageUrl: data.url })) + } + } catch (err) { + console.error('Upload error:', err) + } finally { + setIsUploading(false) + } + } + + const handlePasteFromClipboard = async () => { + try { + const items = await navigator.clipboard.read() + for (const item of items) { + const imageTypes = item.types.filter(t => t.startsWith('image/')) + for (const type of imageTypes) { + const blob = await item.getType(type) + const file = new File([blob], 'pasted-image.png', { type }) + handleUpload(file) + return + } + } + alert('No image found in clipboard') + } catch (err) { + console.error('Clipboard error:', err) + alert('Please allow clipboard access or copy an image first.') + } + } + + const openAddModal = () => { + setEditingHero({ + tagline: 'A New Paradigm of Calm', + title: 'Inhale [Serenity]', + description: 'Crafting premium sensory experiences that transform your space into a sanctuary of peace.', + imageUrl: '/images/hero.png', + buttonText: 'Explore Collection', + buttonLink: '/shop', + isActive: false + }) + setIsModalOpen(true) + } + + const openEditModal = (hero: Hero) => { + setEditingHero(hero) + setIsModalOpen(true) + } + + return ( +
+ {!isModalOpen ? ( + <> +
+
+

Hero Management

+

Control the first impression of your sanctuary.

+
+ +
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ )) + ) : heroes.length === 0 ? ( +
+ +

No hero entries found. Create your first one!

+
+ ) : ( + heroes.map((hero) => ( +
+
+ {/* Preview Image */} +
+ Hero Preview + {!hero.isActive && ( +
+ Inactive +
+ )} +
+ + {/* Content */} +
+
+
+
+ + {hero.tagline} + + {hero.isActive && ( + + + Live on Site + + )} +
+

ID: HERO-{hero.id.toString().padStart(3, '0')}

+
+ +
+

+ {hero.title.split('[').map((part, i) => { + if (part.includes(']')) { + const [accent, rest] = part.split(']') + return ( + + {accent} + {rest} + + ) + } + return {part} + })} +

+

+ {hero.description} +

+
+ +
+
+ + {hero.buttonText} +
+
+ + {hero.buttonLink} +
+
+
+ +
+ {!hero.isActive && ( + + )} + + +
+
+
+
+ )) + )} +
+ + ) : ( + editingHero && ( +
+
+

+ + {editingHero.id ? 'Edit Hero Section' : 'Create New Hero'} +

+ +
+ +
+
+ {/* Image Upload Area */} +
+
+ + +
+
document.getElementById('hero-upload')?.click()} + > + {editingHero.imageUrl ? ( + <> + Hero Background +
+ +
+ + ) : ( +
+ +

Select Image

+
+ )} + + {isUploading && ( +
+ +
+ )} + { + const file = e.target.files?.[0] + if (file) handleUpload(file) + }} + /> +
+
+ +

Recommended size: 1920x1080px. High quality but optimized for web.

+
+
+ + {/* Content Fields */} +
+ + setEditingHero({ ...editingHero, tagline: e.target.value })} + className="admin-input w-full" + placeholder="e.g. A New Paradigm of Calm" + /> +
+ +
+ + setEditingHero({ ...editingHero, title: e.target.value })} + className="admin-input w-full" + placeholder="e.g. Inhale [Serenity]" + /> +

Use [brackets] for the accent serif font.

+
+ +
+ +