Compare commits

...
27 Commits
Author SHA1 Message Date
Your Name f49776f9cb x 2026-10-18 13:55:18 +07:00
Your Name 4fcdd8a816 feat(mcp): add BOM management tools (list_products_bom_status, get_product_bom, set_product_bom) 2026-10-18 12:25:35 +07:00
Your Name b030c08f68 feat(raw-materials): add inline price editing with keyboard shortcuts and partial update support 2026-10-18 11:56:38 +07:00
Your Name a5584d0d46 fix(raw-materials): remove invalid sku field from product select and resolve localized name 2026-10-18 11:44:51 +07:00
adminandYour Name a0abfbf306 feat(bom): add BOM JSON import modal with auto-matching and live cost preview (#12)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #12
2026-10-18 04:24:17 +00:00
antigravity-bot BotandYour Name d8f6d8139f feat(bom): add Get Prompt modal for LLM and fix product name resolution (#11)
### Summary of Changes
- Added 'Get Prompt' button and ProductBomPromptModal dialog on /products/:id/bom.
- Formats product ID, variants, and raw material inventory into a structured prompt for LLMs.
- Fixed backend bug in GET /products/:id?lang=all that wiped product.name when no translations existed.
- Added multi-level fallback in frontend to ensure product name never shows 'Unknown Product'.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #11
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:55:15 +00:00
antigravity-bot BotandYour Name a647c9682d feat(raw-materials): add bulk JSON import with upsert and full-page edit routes (#10)
Fixes #9

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #10
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:30:19 +00:00
antigravity-bot Bot b13f37cf7e Merge pull request 'docs: audit and tidy legacy agent workspaces and historical records' (#8) from docs/historical-agents into master 2026-10-18 02:51:35 +00:00
Your Name 1ce711d960 docs: audit and tidy legacy agent workspaces and historical records 2026-10-18 09:50:58 +07:00
antigravity-bot Bot 809f420fe4 Merge pull request 'docs: label tenant examples and LLM prompt guides' (#7) from docs/tenant-prompts into master 2026-10-18 02:49:34 +00:00
Your Name cbd816592b docs: label tenant examples and LLM prompt guides 2026-10-18 09:48:54 +07:00
antigravity-bot Bot 408f07e27a Merge pull request 'docs: add scope labels to deployment runbooks and postmortem checklists' (#6) from docs/deployment-runbooks into master 2026-10-18 02:44:40 +00:00
Your Name 468b690ac3 docs: add scope labels to deployment runbooks and postmortem checklists 2026-10-18 09:42:04 +07:00
antigravity-bot BotandYour Name 18c12a6a14 docs: modernize platform capability and architecture guides to DBK Platform (#5)
Fixes #1

### Summary of Changes
- Modernized architecture documents to establish DBK Platform as the canonical multi-tenant commerce platform identity, treating HomAroma as the reference tenant.
- Updated frontend comparison to accurately reflect apps/spa (React 19 + Vite) vs apps/website (Next.js 15 SSR) instead of legacy names.
- Cleaned typos in PRISMA7_GUIDE and added cross-references to docs/PLATFORM_OVERVIEW.md.
- Updated docs/DOCUMENTATION_MIGRATION.md completion tracking.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #5
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 01:47:59 +00:00
Your Name ce252dc061 feat(tools): add projects support to gitea helper CLI 2026-10-18 08:18:41 +07:00
Your Name 8a8438096c Test Add Gitea 2026-10-18 08:13:44 +07:00
Your Name 765cdd1fa9 x 2026-10-18 08:01:36 +07:00
unknown dd79519bf7 feat(deploy): add deployment configs, post-receive hook, and nginx reverse proxy for foxxio 2026-10-17 16:22:06 +07:00
unknown 0b6e5310c0 docs: add api.bigbike.ddcsprocket.com gateway nginx config 2026-10-17 16:00:33 +07:00
unknown 1be4657f3f docs: add gateway nginx proxy config for 192.168.1.24 2026-10-17 15:46:12 +07:00
unknown 647428961a x 2026-10-17 14:37:55 +07:00
unknown 731619af9d x 2026-10-17 14:25:27 +07:00
unknown 5ba4afb2de BigBike 2026-10-17 13:34:07 +07:00
unknown 2364aa32b0 chore: add .gitattributes to normalize bun.lock and text line endings 2026-10-17 12:21:16 +07:00
Your Name ca79692b3e fix(backend): enhance sequence discovery from sister website directories and sync in post-receive 2026-10-17 11:26:29 +07:00
Your Name 0bf544720b feat(cms): add VDO Frame Scrubber block with isolated sequence library 2026-10-17 11:23:08 +07:00
Your Name 64f7ecf729 feat(cms): add Interactive 360 Turntable and Macro Zoom Showcase block 2026-10-17 09:25:16 +07:00
954 changed files with 12300 additions and 340 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Original User Request
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
## Initial Request — 2026-06-28T10:50:33Z
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
+32
View File
@@ -0,0 +1,32 @@
# Agent System & Workspaces Directory
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
---
## 1. Active Skills (`.agents/skills/`)
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
- **`cms-content-manager`**: Content management and MCP blackbox testing.
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
---
## 2. Historical Subagent Workspaces
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
- **Status:** Historical records preserved for provenance, auditability, and debugging.
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
+11
View File
@@ -0,0 +1,11 @@
# Handle line endings automatically for files detected as text
* text=auto
# Lockfile must use LF across all platforms for Bun consistency
bun.lock text eol=lf
# Shell scripts must use LF
*.sh text eol=lf
# Windows batch scripts must use CRLF
*.bat text eol=crlf
+2 -2
View File
@@ -1,6 +1,6 @@
# DBK Platform Admin Section Guide
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant.
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
### `SCENT_NOTES_BREAKDOWN`
- Purpose: Fragrance layer breakdown
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
- Contains:
- `title` localized string optional
- `description` localized string optional
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decisions — DBK Platform
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource.
> **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
> อัปเดตล่าสุด: 2026-07-12
+2 -2
View File
@@ -1,7 +1,7 @@
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
+3 -3
View File
@@ -1,8 +1,8 @@
# Custom Block Flow Guide
# Custom Block Flow Guide — DBK Platform
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant.
> **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
## แนวคิดหลัก
+1 -1
View File
@@ -1,6 +1,6 @@
# DDC Deployment Runbook
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it.
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
+1 -1
View File
@@ -1,6 +1,6 @@
# HuaHin Deployment Runbook
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# Kittisak Deployment Runbook
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
+61
View File
@@ -0,0 +1,61 @@
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
## 📦 สรุปสถานะภาพรวม
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
+4 -4
View File
@@ -1,14 +1,14 @@
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป)
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
xx
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
---
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|---|---|---|
+1 -1
View File
@@ -2,7 +2,7 @@
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
+1 -1
View File
@@ -1,6 +1,6 @@
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding.
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
+3
View File
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage'
import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage'
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
import LoginPage from './pages/LoginPage'
import InstallSeedPage from './pages/InstallSeedPage'
import ProtectedRoute from './components/ProtectedRoute'
@@ -123,6 +124,8 @@ function App() {
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
<Route path="/settings" element={<StoreSettingsPage />} />
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
if (!isOpen) return null;
return (
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
{/* Header */}
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import {
Sparkles,
Copy,
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
### 1. Root Items (เมนูแถวบนสุด - Level 1)
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
}
}
return (
return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
{/* Header */}
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
</div>
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,390 @@
import { useState, useMemo } from 'react';
import {
X,
FileJson,
Upload,
CheckCircle2,
AlertCircle,
Layers,
Sparkles
} from 'lucide-react';
interface ProductBomImportModalProps {
isOpen: boolean;
onClose: () => void;
variants: any[];
rawMaterials: any[];
onApply: (updatedVariants: any[]) => void;
}
export default function ProductBomImportModal({
isOpen,
onClose,
variants,
rawMaterials,
onApply,
}: ProductBomImportModalProps) {
const [jsonInput, setJsonInput] = useState('');
// Real-time Parser & Matcher
const parsedResult = useMemo(() => {
if (!jsonInput.trim()) return null;
try {
const parsed = JSON.parse(jsonInput);
if (!Array.isArray(parsed)) {
return {
valid: false,
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
matchedVariants: [],
unmatchedCount: 0,
};
}
if (parsed.length === 0) {
return {
valid: false,
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
matchedVariants: [],
unmatchedCount: 0,
};
}
const matched: any[] = [];
let unmatchedCount = 0;
for (const entry of parsed) {
// 1. Match Variant by ID or SKU or Option
const variant = variants.find((v) => {
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
return false;
});
if (!variant) {
unmatchedCount++;
continue;
}
// 2. Match BOM items to RawMaterials
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
const resolvedBomItems: any[] = [];
let totalCost = 0;
for (const b of rawItems) {
const qty = Number(b.quantity) || 0;
if (qty <= 0) continue;
// Find raw material by ID or Name
const rm = rawMaterials.find((m) => {
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
return false;
});
if (rm) {
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
totalCost += lineCost;
resolvedBomItems.push({
rawMaterialId: rm.id,
name: rm.name,
unit: rm.unit,
quantity: qty,
pricePerUnit: rm.pricePerUnit,
lineCost,
});
} else {
// Unresolved material ID or name
resolvedBomItems.push({
rawMaterialId: Number(b.rawMaterialId) || 0,
name: b.name || `Material #${b.rawMaterialId}`,
unit: 'unit',
quantity: qty,
pricePerUnit: 0,
lineCost: 0,
unknown: true,
});
}
}
const price = Number(variant.price) || 0;
const profit = price - totalCost;
const margin = price > 0 ? (profit / price) * 100 : 0;
matched.push({
targetVariant: variant,
resolvedBomItems,
totalCost,
price,
profit,
margin,
});
}
if (matched.length === 0) {
return {
valid: false,
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
matchedVariants: [],
unmatchedCount,
};
}
return {
valid: true,
error: null,
matchedVariants: matched,
unmatchedCount,
};
} catch (err: any) {
return {
valid: false,
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
matchedVariants: [],
unmatchedCount: 0,
};
}
}, [jsonInput, variants, rawMaterials]);
if (!isOpen) return null;
const handleApply = () => {
if (!parsedResult || !parsedResult.valid) return;
// Deep copy current variants
const newVariants = JSON.parse(JSON.stringify(variants));
for (const match of parsedResult.matchedVariants) {
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
if (idx !== -1) {
// Map to exact BOM structure expected by ProductBomPage
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
}));
}
}
onApply(newVariants);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
Auto-Match & Calculate
</span>
</div>
<p className="text-xs text-slate-400">
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{/* Textarea Input */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
</label>
{jsonInput && (
<button
type="button"
onClick={() => setJsonInput('')}
className="text-rose-400 hover:text-rose-300 font-medium"
>
ล้างข้อความ
</button>
)}
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
rows={8}
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
/>
</div>
{/* Validation Status Banner */}
{parsedResult && (
<div
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
parsedResult.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
<div className="flex items-center gap-2.5">
{parsedResult.valid ? (
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
)}
<span>
{parsedResult.valid ? (
<>
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
</>
) : (
parsedResult.error
)}
</span>
</div>
{parsedResult.valid && (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
พร้อมนำเข้า
</span>
)}
</div>
)}
{/* Live Preview Cards */}
{parsedResult?.valid && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-bold text-white flex items-center gap-1.5">
<Layers className="w-4 h-4 text-cyan-400" />
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
</span>
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
</div>
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
{parsedResult.matchedVariants.map((item: any, idx: number) => {
const v = item.targetVariant;
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
return (
<div
key={v.id || idx}
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
#{v.id}
</span>
<span className="text-sm font-bold text-white">{label}</span>
{v.sku && (
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
SKU: {v.sku}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
</span>
<span className="text-slate-400">
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
</span>
<span className="text-slate-400">
กำไร:{' '}
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
</strong>
</span>
</div>
</div>
{/* Ingredients Breakdown */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{item.resolvedBomItems.map((b: any, bIdx: number) => (
<div
key={bIdx}
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
b.unknown
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
: 'bg-night-950/60 border-white/5 text-slate-300'
}`}
>
<div className="space-y-0.5 min-w-0 pr-2">
<p className="font-medium truncate text-white text-[11px]">
{b.name}
</p>
<p className="text-[10px] text-slate-500">
ID: #{b.rawMaterialId}
</p>
</div>
<div className="text-right shrink-0">
<span className="font-mono font-bold text-cyan-400">
{b.quantity} {b.unit}
</span>
<p className="text-[10px] text-slate-500 font-mono">
฿{b.lineCost.toFixed(2)}
</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
<button
type="button"
onClick={handleApply}
disabled={!parsedResult?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,332 @@
import { useState, useMemo } from 'react';
import {
X,
Copy,
Check,
Bot,
Sparkles,
FileCode,
Package,
Layers,
Code2
} from 'lucide-react';
interface ProductBomPromptModalProps {
isOpen: boolean;
onClose: () => void;
productId: number | string;
productName: string;
variants: any[];
rawMaterials: any[];
}
export default function ProductBomPromptModal({
isOpen,
onClose,
productId,
productName,
variants,
rawMaterials,
}: ProductBomPromptModalProps) {
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
// Generate full prompt text for LLM
const generatedPrompt = useMemo(() => {
const variantLines = variants.map((v) => {
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
}).join('\n');
const rawMaterialLines = rawMaterials.map((rm) => {
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
}).join('\n');
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
[บริบทข้อมูลสินค้า]:
- รหัสสินค้า (Product ID): ${productId}
- ชื่อสินค้า: "${productName}"
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
${variantLines}
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
${rawMaterialLines}
[ภารกิจของคุณ]:
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
\`\`\`json
[
{
"variantId": <ตัวเลข ID ของ Variant>,
"sku": "<รหัส SKU>",
"bomItems": [
{
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
"name": "<ชื่อวัตถุดิบ>",
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
}
]
}
]
\`\`\`
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
}, [productId, productName, variants, rawMaterials]);
// Context JSON
const contextJson = useMemo(() => {
return JSON.stringify(
{
product: {
id: Number(productId),
name: productName,
variantsCount: variants.length,
variants: variants.map((v) => ({
id: v.id,
sku: v.sku,
option1: v.option1,
option2: v.option2,
option3: v.option3,
price: v.price,
currentBomCount: v.bomItems?.length || 0,
})),
},
rawMaterialsCount: rawMaterials.length,
rawMaterials: rawMaterials.map((rm) => ({
id: rm.id,
name: rm.name,
unit: rm.unit,
pricePerUnit: rm.pricePerUnit,
})),
},
null,
2
);
}, [productId, productName, variants, rawMaterials]);
// Target schema sample
const schemaSample = useMemo(() => {
const firstVariant = variants[0];
const sampleItem = rawMaterials[0];
return JSON.stringify(
[
{
variantId: firstVariant ? firstVariant.id : 1,
sku: firstVariant?.sku || 'SAMPLE-SKU',
bomItems: [
{
rawMaterialId: sampleItem ? sampleItem.id : 1,
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
quantity: 1,
},
],
},
],
null,
2
);
}, [variants, rawMaterials]);
if (!isOpen) return null;
const handleCopy = async (textToCopy: string) => {
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error(e);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<Bot className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
Product #{productId}
</span>
</div>
<p className="text-xs text-slate-400">
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Stats Bar */}
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
<div className="flex items-center gap-2 text-slate-400">
<Package className="w-4 h-4 text-cyan-400" />
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Layers className="w-4 h-4 text-purple-400" />
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Code2 className="w-4 h-4 text-emerald-400" />
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => setActiveTab('prompt')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'prompt'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Sparkles className="w-4 h-4" />
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
</button>
<button
onClick={() => setActiveTab('context')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'context'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<FileCode className="w-4 h-4" />
<span>ข้อมูลดิบ (Context JSON)</span>
</button>
<button
onClick={() => setActiveTab('schema')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'schema'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Code2 className="w-4 h-4" />
<span>Target Output Format</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-4 flex-1">
{activeTab === 'prompt' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
</span>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
</button>
</div>
<textarea
readOnly
value={generatedPrompt}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'context' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
</span>
<button
type="button"
onClick={() => handleCopy(contextJson)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
</button>
</div>
<textarea
readOnly
value={contextJson}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'schema' && (
<div className="space-y-4">
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
<p className="text-slate-400">
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
</p>
</div>
<textarea
readOnly
value={schemaSample}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ปิดหน้าต่าง
</button>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import {
Sparkles,
Copy,
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
}
}
return (
return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,419 @@
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
Loader2,
Sparkles,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
interface RawMaterialBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
materials: RawMaterial[];
onSuccess: () => void;
}
const SAMPLE_TEMPLATE = [
{
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
unit: "piece",
pricePerUnit: 18.50
},
{
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
unit: "pack",
pricePerUnit: 7.00
},
{
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
unit: "kg",
pricePerUnit: 2400.00
},
{
name: "เบสกระจายความหอม MMB (Diffuser Base)",
unit: "liter",
pricePerUnit: 350.00
}
];
export default function RawMaterialBulkJsonModal({
isOpen,
onClose,
materials,
onSuccess,
}: RawMaterialBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [copiedTemplate, setCopiedTemplate] = useState(false);
const [copiedExport, setCopiedExport] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Parse & validate input text in real time
const parsedPreview = useMemo(() => {
if (!importJsonText.trim()) return null;
try {
const parsed = JSON.parse(importJsonText);
if (!Array.isArray(parsed)) {
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
}
if (parsed.length === 0) {
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
}
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
if (invalidItem) {
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
}
return { valid: true, error: null, count: parsed.length, items: parsed };
} catch (err: any) {
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
}
}, [importJsonText]);
// Export JSON string
const exportJsonString = useMemo(() => {
const cleanList = materials.map((m) => ({
name: m.name,
unit: m.unit,
pricePerUnit: m.pricePerUnit,
}));
return JSON.stringify(cleanList, null, 2);
}, [materials]);
if (!isOpen) return null;
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleCopyExport = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedExport(true);
setTimeout(() => setCopiedExport(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleDownloadExport = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setImportJsonText(content);
setResultMessage(null);
};
reader.readAsText(file);
e.target.value = '';
};
const handleImportSubmit = async () => {
if (!parsedPreview || !parsedPreview.valid) return;
setSubmitting(true);
setResultMessage(null);
try {
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedPreview.items),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
}
setResultMessage({
type: 'success',
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
});
setImportJsonText('');
setTimeout(() => {
onSuccess();
onClose();
}, 1200);
} catch (err: any) {
setResultMessage({
type: 'error',
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
});
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={() => !submitting && onClose()}
/>
{/* Modal Container */}
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
Raw Materials JSON Tool
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
Bulk Upsert
</span>
</h2>
<p className="text-xs text-slate-400">
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
</p>
</div>
</div>
<button
onClick={onClose}
disabled={submitting}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'import'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Upload className="w-4 h-4" />
<span>นำเข้า JSON (Bulk Import)</span>
</button>
<button
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'export'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Download className="w-4 h-4" />
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{activeTab === 'import' ? (
<div className="space-y-5">
{/* Guidance Notice */}
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
<Sparkles className="w-3.5 h-3.5" />
กลไกอัจฉริยะ (Upsert Automation)
</div>
<p className="text-slate-300 leading-relaxed">
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
</p>
</div>
<button
type="button"
onClick={handleCopyTemplate}
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
>
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
</button>
</div>
{/* JSON Input Area */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
>
<Upload className="w-3.5 h-3.5" />
<span>อัปโหลดไฟล์ .json</span>
</button>
{importJsonText && (
<button
type="button"
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
>
ล้างข้อมูล
</button>
)}
</div>
</div>
<textarea
value={importJsonText}
onChange={(e) => {
setImportJsonText(e.target.value);
setResultMessage(null);
}}
rows={9}
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
/>
</div>
{/* Live Preview / Validation Status */}
{parsedPreview && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
parsedPreview.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
{parsedPreview.valid ? (
<>
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{parsedPreview.error}</span>
</>
)}
</div>
)}
{/* Server Result Message */}
{resultMessage && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
resultMessage.type === 'success'
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
}`}
>
{resultMessage.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 shrink-0" />
)}
<span>{resultMessage.text}</span>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyExport}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
>
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadExport}
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>ดาวน์โหลด .json</span>
</button>
</div>
</div>
<textarea
readOnly
value={exportJsonString}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
{activeTab === 'import' && (
<button
type="button"
onClick={handleImportSubmit}
disabled={submitting || !parsedPreview?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>กำลังบันทึกข้อมูล...</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
X,
@@ -143,7 +144,61 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
</div>
)
},
// 2.9 APPLE_SHOWCASE
// 2.96 VDO_FRAME_SCRUBBER
{
type: 'VDO_FRAME_SCRUBBER',
category: 'hero',
title: 'VDO FRAME SCRUBBER (Scrollytelling Video)',
description: 'Cinema-quality video frame sequence (432 frames) with scroll scrub, direct drag, range slider, and play/pause at 24fps without memory leaks.',
badge: 'Video Frames · 4K',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0e0e0d] flex items-center justify-between p-3 border border-[#d5ff63]/30">
<div className="space-y-1 max-w-[65%] z-10">
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63] animate-ping" /> FRAME SCRUBBER
</span>
<h5 className="text-[10px] font-black text-white leading-tight">
FROM VIDEO TO INTERACTIVE
</h5>
<div className="flex items-center gap-2 text-[6px] text-slate-400 font-mono">
<span className="px-1.5 py-0.5 rounded bg-white/10 text-white">432 FRAMES</span>
<span>00:18.0s @ 24FPS</span>
</div>
</div>
<div className="w-12 h-12 rounded-2xl bg-black/80 border border-[#d5ff63]/40 flex items-center justify-center text-[#d5ff63] shadow-xl shrink-0">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</div>
)
},
// 2.95 INTERACTIVE_360_SHOWCASE
{
type: 'INTERACTIVE_360_SHOWCASE',
category: 'hero',
title: 'INTERACTIVE 360° TURNTABLE SHOWCASE',
description: 'Ultra-fast 2.5D Sprite Sheet 360° turntable, scroll-driven rotation, interactive drag-to-spin, and seamless macro detail zoom transition.',
badge: '3D Web · Hot',
renderPreview: () => (
<div className="relative w-full aspect-[21/9] rounded-xl overflow-hidden bg-[#0f0f0e] flex items-center justify-between p-3 border border-[#d5ff63]/30">
<div className="space-y-1 max-w-[60%] z-10">
<span className="text-[6px] uppercase font-mono font-bold text-[#d5ff63] tracking-widest">
360° TURNTABLE + MACRO ZOOM
</span>
<h5 className="text-[10px] font-black text-white leading-tight">
PRECISION TO EVERY ANGLE
</h5>
<div className="flex items-center gap-1 text-[6px] text-slate-400 font-mono">
<span className="w-1.5 h-1.5 rounded-full bg-[#d5ff63]" />
<span>16-Angle Turntable Scrub</span>
</div>
</div>
<div className="w-14 h-14 rounded-full bg-black/60 border border-[#d5ff63]/40 flex items-center justify-center p-1 shadow-2xl shrink-0">
<span className="text-[9px] font-bold font-mono text-[#d5ff63]">360°</span>
</div>
</div>
)
},
// 2.9 APPLE_SHOWCASE
{
type: 'APPLE_SHOWCASE',
category: 'hero',
@@ -798,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
return matchesCategory && matchesSearch;
});
return (
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
{/* Modal Header */}
@@ -924,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,365 @@
import { useState, useEffect } from 'react'
import {
X,
Upload,
Film,
CheckCircle,
Loader2,
Trash2,
FolderOpen,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
export interface FrameSequenceItem {
id: string
name: string
slug: string
folderPath: string
pattern: string
padding: number
totalFrames: number
fps: number
durationSeconds: number
posterUrl: string
createdAt: string
}
interface Props {
isOpen: boolean
onClose: () => void
onSelectSequence: (seq: FrameSequenceItem) => void
selectedSlug?: string
}
export default function FrameSequenceModal({
isOpen,
onClose,
onSelectSequence,
selectedSlug,
}: Props) {
const [sequences, setSequences] = useState<FrameSequenceItem[]>([])
const [loading, setLoading] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
// Upload Form State
const [showUploadForm, setShowUploadForm] = useState(false)
const [uploadName, setUploadName] = useState('')
const [uploadFps, setUploadFps] = useState('24')
const [zipFile, setZipFile] = useState<File | null>(null)
const fetchSequences = async () => {
setLoading(true)
try {
const res = await fetch(`${API_BASE}/frame-sequences`)
if (!res.ok) return
const data = await res.json()
setSequences(data.sequences || [])
} catch (err) {
console.error('Failed to load frame sequences', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
fetchSequences()
setShowUploadForm(false)
setUploadError(null)
}
}, [isOpen])
if (!isOpen) return null
const handleUploadZip = async (e: React.FormEvent) => {
e.preventDefault()
if (!zipFile) {
setUploadError('กรุณาเลือกไฟล์ .ZIP ที่มีรูปเฟรม')
return
}
setIsUploading(true)
setUploadError(null)
const formData = new FormData()
formData.append('file', zipFile)
if (uploadName) formData.append('name', uploadName)
formData.append('fps', uploadFps)
try {
const res = await fetch(`${API_BASE}/frame-sequences/upload`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok || data.error) {
throw new Error(data.error || 'Upload failed')
}
await fetchSequences()
setShowUploadForm(false)
setZipFile(null)
setUploadName('')
if (data.sequence) {
onSelectSequence(data.sequence)
onClose()
}
} catch (err: any) {
setUploadError(err.message || 'เกิดข้อผิดพลาดในการอัปโหลด ZIP')
} finally {
setIsUploading(false)
}
}
const handleDelete = async (slug: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบชุดเฟรม "${slug}"?`)) return
try {
const res = await fetch(`${API_BASE}/frame-sequences/${slug}`, {
method: 'DELETE',
})
if (res.ok) {
setSequences((prev) => prev.filter((s) => s.slug !== slug))
}
} catch (err) {
console.error('Failed to delete sequence', err)
}
}
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in">
<div className="bg-[#121211] border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[85vh] overflow-hidden text-slate-200">
{/* Modal Header */}
<div className="p-6 border-b border-white/5 flex items-center justify-between shrink-0 bg-black/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-[#d5ff63]/10 border border-[#d5ff63]/30 flex items-center justify-center text-[#d5ff63]">
<Film className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<span>คลังชุดวิดีโอเฟรม (Frame Sequence Library)</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[#d5ff63]/20 text-[#d5ff63] font-mono">
แยกจาก Media ปกติ
</span>
</h3>
<p className="text-xs text-slate-400">
เลือกชุดเฟรมภาพแบบ 360° / Scrollytelling โดยไม่ปะปนกับคลังภาพสินค้าทั่วไป
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowUploadForm(!showUploadForm)}
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-bold text-white flex items-center gap-2 transition-all cursor-pointer"
>
<Upload className="w-3.5 h-3.5 text-[#d5ff63]" />
<span>{showUploadForm ? 'ดูรายการที่มีอยู่' : 'อัปโหลด ZIP ชุดใหม่'}</span>
</button>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Modal Body */}
<div className="p-6 flex-1 overflow-y-auto space-y-6">
{/* Upload Form Accordion */}
{showUploadForm && (
<form
onSubmit={handleUploadZip}
className="p-5 rounded-2xl bg-black/60 border border-[#d5ff63]/30 space-y-4 animate-fade-in"
>
<div className="flex items-center gap-2 text-xs font-bold text-[#d5ff63] uppercase tracking-wider">
<Upload className="w-4 h-4" />
<span>อัปโหลดชุดเฟรมภาพด้วยไฟล์ .ZIP</span>
</div>
<p className="text-xs text-slate-400">
บีบอัดโฟลเดอร์ภาพ เช่น <code className="text-slate-200">frame-001.webp</code> ถึง{' '}
<code className="text-slate-200">frame-432.webp</code> เป็นไฟล์ .zip แล้วอัปโหลด ระบบจะคลายไฟล์ให้อัตโนมัติ
</p>
{uploadError && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-xs text-red-400">
{uploadError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ชื่อชุดวิดีโอ (Name)
</label>
<input
type="text"
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
placeholder="เช่น Chrome Sprocket 18s Spin"
className="admin-input w-full text-xs"
required
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ความเร็วเฟรมต่อวินาที (FPS)
</label>
<select
value={uploadFps}
onChange={(e) => setUploadFps(e.target.value)}
className="admin-input w-full text-xs"
>
<option value="24">24 FPS (Cinematic Video - ค่ามาตรฐาน)</option>
<option value="30">30 FPS (Standard Video)</option>
<option value="60">60 FPS (Ultra Smooth 3D)</option>
<option value="12">12 FPS (Stop Motion)</option>
</select>
</div>
</div>
<div>
<label className="text-[10px] font-bold uppercase text-slate-400 block mb-1">
ไฟล์ .ZIP ที่มีรูปเฟรม
</label>
<input
type="file"
accept=".zip"
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
className="admin-input w-full text-xs"
required
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setShowUploadForm(false)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs text-slate-300"
>
ยกเลิก
</button>
<button
type="submit"
disabled={isUploading || !zipFile}
className="px-6 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] disabled:opacity-50 flex items-center gap-2 cursor-pointer shadow-lg"
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>กำลังแตกไฟล์และจัดเก็บ...</span>
</>
) : (
<>
<Upload className="w-3.5 h-3.5" />
<span>เริ่มอัปโหลดและประมวลผล ZIP</span>
</>
)}
</button>
</div>
</form>
)}
{/* Sequence Cards Grid */}
{loading ? (
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-[#d5ff63]" />
<span className="text-xs">กำลังโหลดชุดวิดีโอเฟรม...</span>
</div>
) : sequences.length === 0 ? (
<div className="py-20 text-center space-y-3">
<FolderOpen className="w-12 h-12 text-slate-600 mx-auto" />
<div className="text-sm font-bold text-slate-300">ยังไม่มีชุด Frame Sequence ในระบบ</div>
<p className="text-xs text-slate-500 max-w-sm mx-auto">
คุณสามารถกดปุ่ม "อัปโหลด ZIP ชุดใหม่" ด้านบนเพื่อนำชุดเฟรมภาพเข้ามาใช้งานได้ทันที
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{sequences.map((seq) => {
const isSelected = selectedSlug === seq.slug
return (
<div
key={seq.id}
onClick={() => {
onSelectSequence(seq)
onClose()
}}
className={`relative group rounded-2xl bg-black/40 border transition-all p-3 space-y-3 cursor-pointer ${
isSelected
? 'border-[#d5ff63] ring-1 ring-[#d5ff63] shadow-lg shadow-[#d5ff63]/10'
: 'border-white/10 hover:border-white/20'
}`}
>
{/* Poster Thumbnail */}
<div className="relative aspect-video rounded-xl bg-slate-900 overflow-hidden border border-white/5">
<img
src={seq.posterUrl}
alt={seq.name}
className="w-full h-full object-contain p-2"
/>
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/80 backdrop-blur-md text-[9px] font-mono text-white">
{seq.totalFrames} FRAMES • {seq.durationSeconds}s
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-[#d5ff63] text-black flex items-center justify-center shadow-lg">
<CheckCircle className="w-4 h-4 fill-black text-[#d5ff63]" />
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-1">
<h4 className="text-sm font-bold text-white group-hover:text-[#d5ff63] transition-colors truncate">
{seq.name}
</h4>
<p className="text-[10px] font-mono text-slate-500 truncate">
{seq.folderPath}
</p>
</div>
{/* Action Bar */}
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px]">
<span className="font-mono text-slate-400">
{seq.fps} FPS
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => handleDelete(seq.slug, e)}
title="ลบชุดนี้"
className="p-1 rounded hover:bg-red-500/20 text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="px-3 py-1 rounded-lg bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e]"
>
เลือกชุดนี้
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,548 @@
import { useState, useEffect } from 'react'
import {
Sparkles,
Upload,
Package,
ExternalLink,
RotateCcw,
Layers,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
interface SpecItem {
number?: string
title: string
subtitle?: string
}
interface Props {
payload: Record<string, unknown>
updatePayloadValue: (field: string, value: unknown) => void
onBrowseMedia?: (onSelect: (url: string) => void) => void
}
const DEFAULT_SPECS: SpecItem[] = [
{
number: '01',
title: 'โครเมียมขัดเงาระดับพรีเมียม',
subtitle: 'POLISHED CHROME FINISH',
},
{
number: '02',
title: 'เจาะรูกลึงลดน้ำหนักตามหลักอากาศพลศาสตร์',
subtitle: 'LIGHTWEIGHT PRECISION CUTOUTS',
},
{
number: '03',
title: 'โปรไฟล์ฟันเฟือง 428H แม่นยำระดับไมครอน',
subtitle: '428H PRECISION TOOTH PROFILE',
},
]
export default function Interactive360ShowcaseForm({
payload,
updatePayloadValue,
onBrowseMedia,
}: Props) {
const [productsList, setProductsList] = useState<any[]>([])
const [loadingProducts, setLoadingProducts] = useState(false)
const spriteSheetUrl = (payload.spriteSheetUrl as string) || '/images/3d/sprocket-views.png'
const detailImageUrl = (payload.detailImageUrl as string) || '/images/3d/sprocket-detail.png'
const eyebrow = (payload.eyebrow as string) || ''
const title = (payload.title as string) || ''
const subtitle = (payload.subtitle as string) || ''
const detailKicker = (payload.detailKicker as string) || ''
const detailTitle = (payload.detailTitle as string) || ''
const detailCaption = (payload.detailCaption as string) || ''
const productName = (payload.productName as string) || ''
const productBadge = (payload.productBadge as string) || ''
const accentColor = (payload.accentColor as string) || '#d5ff63'
const theme = (payload.theme as string) || 'dark'
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
const endCtaLink = (payload.endCtaLink as string) || ''
const selectedProductId = payload.productId ? String(payload.productId) : ''
const rawSpecs = Array.isArray(payload.specs) ? (payload.specs as SpecItem[]) : DEFAULT_SPECS
useEffect(() => {
let isMounted = true
const fetchCatalog = async () => {
setLoadingProducts(true)
try {
const res = await fetch(`${API_BASE}/products?limit=100`)
if (!res.ok) return
const data = await res.json()
const items = data.data || data.products || (Array.isArray(data) ? data : [])
if (isMounted) setProductsList(items)
} catch (err) {
console.error('Failed to load products in Interactive360ShowcaseForm', err)
} finally {
if (isMounted) setLoadingProducts(false)
}
}
fetchCatalog()
return () => { isMounted = false }
}, [])
// Auto-seed defaults if brand new block
useEffect(() => {
if (!payload.spriteSheetUrl) updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
if (!payload.detailImageUrl) updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
if (!payload.title) updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
if (!payload.subtitle) updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
if (!payload.detailKicker) updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
if (!payload.detailTitle) updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
if (!payload.detailCaption) updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
if (!payload.productName) updatePayloadValue('productName', 'Chrome Sprocket 428H')
if (!payload.productBadge) updatePayloadValue('productBadge', 'MONO / 01')
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
if (!payload.specs) updatePayloadValue('specs', DEFAULT_SPECS)
if (!payload.theme) updatePayloadValue('theme', 'dark')
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
}, [])
const handleSelectProduct = (productIdStr: string) => {
if (!productIdStr) {
updatePayloadValue('productId', null)
return
}
const pid = Number(productIdStr)
updatePayloadValue('productId', pid)
const prod = productsList.find((p) => p.id === pid)
if (prod) {
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
if (prodName) updatePayloadValue('productName', prodName)
const slug = prod.slug || `product-${pid}`
updatePayloadValue('endCtaLink', `/products/${slug}`)
}
}
const handleApplyPreset = () => {
updatePayloadValue('spriteSheetUrl', '/images/3d/sprocket-views.png')
updatePayloadValue('detailImageUrl', '/images/3d/sprocket-detail.png')
updatePayloadValue('eyebrow', 'ENGINEERED TO MOVE')
updatePayloadValue('title', 'เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา')
updatePayloadValue('subtitle', 'สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ พร้อมสัมผัสรายละเอียดระดับ Ultra-Macro')
updatePayloadValue('detailKicker', 'FINISHED TO THE EDGE')
updatePayloadValue('detailTitle', 'ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ')
updatePayloadValue('detailCaption', 'ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน')
updatePayloadValue('productName', 'Chrome Sprocket 428H')
updatePayloadValue('productBadge', 'MONO / 01')
updatePayloadValue('endCtaTitle', 'พร้อมยกระดับสมรรถนะการขับขี่?')
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
updatePayloadValue('specs', DEFAULT_SPECS)
updatePayloadValue('theme', 'dark')
updatePayloadValue('accentColor', '#d5ff63')
}
const handleUpdateSpec = (idx: number, field: keyof SpecItem, val: string) => {
const updated = [...rawSpecs]
updated[idx] = { ...updated[idx], [field]: val }
updatePayloadValue('specs', updated)
}
return (
<div className="space-y-6 text-slate-200">
{/* ── Preset & Binding Banner ── */}
<div className="p-4 rounded-2xl bg-gradient-to-r from-lime-500/10 via-slate-900/40 to-transparent border border-lime-500/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-lime-400 font-bold text-xs uppercase tracking-wider">
<Sparkles className="w-4 h-4 text-lime-400" />
<span>3D Interactive Turntable & Macro Zoom Showcase</span>
</div>
<p className="text-xs text-slate-400">
แสดงผลสเตอร์/ชิ้นส่วน 360 องศาด้วย Sprite Sheet + Scroll Scrollytelling + ซูม Macro Detail อัตโนมัติ
</p>
</div>
<button
type="button"
onClick={handleApplyPreset}
className="px-4 py-2 rounded-xl bg-lime-400 text-slate-950 font-bold text-xs hover:bg-lime-300 transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>รีเซ็ต / โหลด Mockup สเตอร์โครเมียม</span>
</button>
</div>
{/* ── 1-Click Product Linking ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
<Package className="w-4 h-4 text-lime-400" />
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
</div>
{selectedProductId && (
<span className="text-[10px] text-lime-400 font-mono">
Product ID: #{selectedProductId}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<select
value={selectedProductId}
onChange={(e) => handleSelectProduct(e.target.value)}
disabled={loadingProducts}
className="admin-input w-full text-xs"
>
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
{productsList.map((p) => {
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
return (
<option key={p.id} value={p.id}>
#{p.id} - {nameStr}
</option>
)
})}
</select>
<p className="text-[10px] text-slate-500 mt-1">
เมื่อเลือกสินค้า ระบบจะดึงชื่อและตั้งค่าลิงก์ปุ่มสั่งซื้ออัตโนมัติ
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={endCtaLink}
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
className="admin-input flex-1 text-xs"
/>
{endCtaLink && (
<a
href={endCtaLink}
target="_blank"
rel="noreferrer"
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
{/* ── Visual Media Uploads (Sprite Sheet + Macro Detail) ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Sprite Sheet 16-Angles */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
1. Sprite Sheet 360° (ภาพรวม 16 มุมมอง 4x4 Grid)
</label>
{spriteSheetUrl ? (
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
<img
src={spriteSheetUrl}
alt="360 Sprite Sheet"
className="w-full h-full object-contain p-2"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
{onBrowseMedia && (
<button
type="button"
onClick={() => onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
>
เปลี่ยนรูป
</button>
)}
</div>
</div>
) : (
<div
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('spriteSheetUrl', url))}
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
>
<Upload className="w-6 h-6 text-slate-500" />
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Sprite Sheet</span>
</div>
)}
<input
type="text"
value={spriteSheetUrl}
onChange={(e) => updatePayloadValue('spriteSheetUrl', e.target.value)}
placeholder="/images/3d/sprocket-views.png"
className="admin-input w-full text-xs font-mono"
/>
</div>
{/* Macro Detail Shot */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
<label className="text-xs font-bold text-slate-200 uppercase tracking-wider block">
2. Macro Detail Shot (ภาพซูมระยะประชิดระดับ 4K)
</label>
{detailImageUrl ? (
<div className="relative aspect-video rounded-xl bg-black border border-white/10 overflow-hidden group">
<img
src={detailImageUrl}
alt="Macro Detail"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-opacity">
{onBrowseMedia && (
<button
type="button"
onClick={() => onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
className="px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-bold cursor-pointer"
>
เปลี่ยนรูป
</button>
)}
</div>
</div>
) : (
<div
onClick={() => onBrowseMedia && onBrowseMedia((url) => updatePayloadValue('detailImageUrl', url))}
className="aspect-video rounded-xl border border-dashed border-white/20 bg-black/40 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-lime-400 transition-colors"
>
<Upload className="w-6 h-6 text-slate-500" />
<span className="text-xs text-slate-400">คลิกเพื่อเลือกภาพ Macro Detail</span>
</div>
)}
<input
type="text"
value={detailImageUrl}
onChange={(e) => updatePayloadValue('detailImageUrl', e.target.value)}
placeholder="/images/3d/sprocket-detail.png"
className="admin-input w-full text-xs font-mono"
/>
</div>
</div>
{/* ── Headline & Intro Texts ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
ข้อความหัวเรื่อง (Header & Intro)
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หมวด/ป้ายกำกับ (Eyebrow Tag)
</label>
<input
type="text"
value={eyebrow}
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="ENGINEERED TO MOVE"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ชื่อสินค้า (Product Name)
</label>
<input
type="text"
value={productName}
onChange={(e) => updatePayloadValue('productName', e.target.value)}
className="admin-input w-full text-xs"
placeholder="Chrome Sprocket 428H"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ซีรีส์สินค้า (Badge / Series)
</label>
<input
type="text"
value={productBadge}
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="MONO / 01"
/>
</div>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หัวเรื่องหลัก (Main Title)
</label>
<input
type="text"
value={title}
onChange={(e) => updatePayloadValue('title', e.target.value)}
className="admin-input w-full text-xs"
placeholder="เหล็กหนึ่งชิ้น ที่เล่าได้ทุกองศา"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
คำบรรยายเกริ่นนำ (Intro Subtitle)
</label>
<textarea
value={subtitle}
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
rows={2}
className="admin-input w-full text-xs"
placeholder="สเตอร์จักรยานยนต์โครเมียม ดีไซน์น้ำหนักเบา ให้ทุกการหมุนมีจังหวะที่แม่นยำ"
/>
</div>
</div>
{/* ── Macro Zoom Detail Overlay Texts ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="text-xs font-bold text-lime-400 uppercase tracking-wider flex items-center gap-2">
<Layers className="w-4 h-4" />
<span>ข้อความส่วนซูม Macro Detail (แสดงเมื่อเลื่อนจอลงถึงช่วงท้าย)</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
Kicker Tag
</label>
<input
type="text"
value={detailKicker}
onChange={(e) => updatePayloadValue('detailKicker', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="FINISHED TO THE EDGE"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
Headline ตอนซูม
</label>
<input
type="text"
value={detailTitle}
onChange={(e) => updatePayloadValue('detailTitle', e.target.value)}
className="admin-input w-full text-xs font-bold"
placeholder="ฟันแน่น แข็งแรง ผิวเรียบทุกขอบ"
/>
</div>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
คำอธิบายรายละเอียด
</label>
<textarea
value={detailCaption}
onChange={(e) => updatePayloadValue('detailCaption', e.target.value)}
rows={2}
className="admin-input w-full text-xs"
placeholder="ผิวชุบโครเมียมขัดเงา สะท้อนแสงเงางาม ทนทานต่อการเสียดสี และป้องกันสนิมในทุกสภาพการใช้งาน"
/>
</div>
</div>
{/* ── 3-Spec Highlights Grid ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
จุดเด่นด้านสมรรถนะ 3 หัวข้อ (Technical Pillars)
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{rawSpecs.map((spec, idx) => (
<div key={idx} className="p-3 rounded-xl bg-black/40 border border-white/10 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-mono font-bold text-lime-400">
#{idx + 1}
</span>
<input
type="text"
value={spec.number || `0${idx + 1}`}
onChange={(e) => handleUpdateSpec(idx, 'number', e.target.value)}
className="admin-input w-16 text-center text-xs font-mono"
placeholder={`0${idx + 1}`}
/>
</div>
<input
type="text"
value={spec.title}
onChange={(e) => handleUpdateSpec(idx, 'title', e.target.value)}
className="admin-input w-full text-xs font-bold"
placeholder="หัวข้อสเปก"
/>
<input
type="text"
value={spec.subtitle || ''}
onChange={(e) => handleUpdateSpec(idx, 'subtitle', e.target.value)}
className="admin-input w-full text-xs font-mono text-slate-400"
placeholder="SUBTITLE EN"
/>
</div>
))}
</div>
</div>
{/* ── Theme & CTA Config ── */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
สี Accent Highlight
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={accentColor}
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
/>
<input
type="text"
value={accentColor}
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
className="admin-input flex-1 text-xs font-mono"
/>
</div>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
className="admin-input w-full text-xs"
placeholder="พร้อมยกระดับสมรรถนะการขับขี่?"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ข้อความปุ่มสั่งซื้อ (CTA Button)
</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="สั่งซื้อสินค้าตอนนี้"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
โทนพื้นหลัง (Theme)
</label>
<select
value={theme}
onChange={(e) => updatePayloadValue('theme', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="dark">Dark Theater (#0f0f0e - แนะนำ)</option>
<option value="light">Light Studio (#f8f9fa)</option>
</select>
</div>
</div>
</div>
)
}
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization';
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
onClose();
};
return (
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,434 @@
import { useState, useEffect } from 'react'
import {
Film,
Package,
ExternalLink,
RotateCcw,
CheckCircle,
} from 'lucide-react'
import { API_BASE } from '@homaroma/types'
import FrameSequenceModal, { type FrameSequenceItem } from './FrameSequenceModal'
interface Props {
payload: Record<string, unknown>
updatePayloadValue: (field: string, value: unknown) => void
}
export default function VdoFrameScrubberForm({
payload,
updatePayloadValue,
}: Props) {
const [productsList, setProductsList] = useState<any[]>([])
const [loadingProducts, setLoadingProducts] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const folderPath = (payload.folderPath as string) || '/frame-sequences/chrome-sprocket-428h'
const pattern = (payload.pattern as string) || 'frame-{index}.webp'
const totalFrames = Number(payload.totalFrames) || 432
const fps = Number(payload.fps) || 24
const durationSeconds = Number(payload.durationSeconds) || 18
const eyebrow = (payload.eyebrow as string) || ''
const title = (payload.title as string) || ''
const subtitle = (payload.subtitle as string) || ''
const instruction = (payload.instruction as string) || ''
const productName = (payload.productName as string) || ''
const productBadge = (payload.productBadge as string) || ''
const accentColor = (payload.accentColor as string) || '#d5ff63'
const theme = (payload.theme as string) || 'dark'
const endCtaTitle = (payload.endCtaTitle as string) || ''
const endCtaButtonText = (payload.endCtaButtonText as string) || ''
const endCtaLink = (payload.endCtaLink as string) || ''
const selectedProductId = payload.productId ? String(payload.productId) : ''
useEffect(() => {
let isMounted = true
const fetchCatalog = async () => {
setLoadingProducts(true)
try {
const res = await fetch(`${API_BASE}/products?limit=100`)
if (!res.ok) return
const data = await res.json()
const items = data.data || data.products || (Array.isArray(data) ? data : [])
if (isMounted) setProductsList(items)
} catch (err) {
console.error('Failed to load products in VdoFrameScrubberForm', err)
} finally {
if (isMounted) setLoadingProducts(false)
}
}
fetchCatalog()
return () => { isMounted = false }
}, [])
// Auto-seed defaults on brand new block
useEffect(() => {
if (!payload.folderPath) updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
if (!payload.pattern) updatePayloadValue('pattern', 'frame-{index}.webp')
if (!payload.padding) updatePayloadValue('padding', 3)
if (!payload.totalFrames) updatePayloadValue('totalFrames', 432)
if (!payload.fps) updatePayloadValue('fps', 24)
if (!payload.durationSeconds) updatePayloadValue('durationSeconds', 18)
if (!payload.eyebrow) updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
if (!payload.title) updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
if (!payload.subtitle) updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
if (!payload.instruction) updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
if (!payload.productName) updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
if (!payload.productBadge) updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
if (!payload.endCtaTitle) updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
if (!payload.endCtaButtonText) updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
if (!payload.theme) updatePayloadValue('theme', 'dark')
if (!payload.accentColor) updatePayloadValue('accentColor', '#d5ff63')
}, [])
const handleSelectProduct = (productIdStr: string) => {
if (!productIdStr) {
updatePayloadValue('productId', null)
return
}
const pid = Number(productIdStr)
updatePayloadValue('productId', pid)
const prod = productsList.find((p) => p.id === pid)
if (prod) {
const prodName = typeof prod.name === 'string' ? prod.name : prod.name?.th || prod.name?.en || ''
if (prodName) updatePayloadValue('productName', prodName)
const slug = prod.slug || `product-${pid}`
updatePayloadValue('endCtaLink', `/products/${slug}`)
}
}
const handleApplyPreset = () => {
updatePayloadValue('folderPath', '/frame-sequences/chrome-sprocket-428h')
updatePayloadValue('pattern', 'frame-{index}.webp')
updatePayloadValue('padding', 3)
updatePayloadValue('totalFrames', 432)
updatePayloadValue('fps', 24)
updatePayloadValue('durationSeconds', 18)
updatePayloadValue('eyebrow', 'FROM VIDEO TO INTERACTIVE')
updatePayloadValue('title', 'หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม')
updatePayloadValue('subtitle', 'รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์')
updatePayloadValue('instruction', 'ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ')
updatePayloadValue('productName', 'SPROCKET / FRAME SCRUBBER')
updatePayloadValue('productBadge', '18 SEC / 432 EXTRACTED FRAMES')
updatePayloadValue('endCtaTitle', 'ทุกมุม อยู่ในมือคุณ พร้อมยกระดับการขับขี่')
updatePayloadValue('endCtaButtonText', 'สั่งซื้อสินค้าตอนนี้')
updatePayloadValue('theme', 'dark')
updatePayloadValue('accentColor', '#d5ff63')
}
const handleSequenceSelected = (seq: FrameSequenceItem) => {
updatePayloadValue('folderPath', seq.folderPath)
updatePayloadValue('pattern', seq.pattern || 'frame-{index}.webp')
updatePayloadValue('padding', seq.padding || 3)
updatePayloadValue('totalFrames', seq.totalFrames)
updatePayloadValue('fps', seq.fps || 24)
updatePayloadValue('durationSeconds', seq.durationSeconds || parseFloat((seq.totalFrames / 24).toFixed(1)))
updatePayloadValue('productBadge', `${seq.durationSeconds}S / ${seq.totalFrames} FRAMES`)
}
return (
<div className="space-y-6 text-slate-200">
{/* ── Preset Banner ── */}
<div className="p-4 rounded-2xl bg-gradient-to-r from-[#d5ff63]/10 via-slate-900/40 to-transparent border border-[#d5ff63]/30 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[#d5ff63] font-bold text-xs uppercase tracking-wider">
<Film className="w-4 h-4 text-[#d5ff63]" />
<span>VDO Frame-by-Frame Interactive Scrubber</span>
</div>
<p className="text-xs text-slate-400">
แสดงผลวิดีโอแบบ Frame Sequence ควบคุมเฟรมตามการเลื่อนหน้าเว็บ (Scroll) และการลาก (Drag Scrub)
</p>
</div>
<button
type="button"
onClick={handleApplyPreset}
className="px-4 py-2 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 shrink-0 shadow-lg cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>รีเซ็ต / โหลดตัวอย่าง Chrome Sprocket (432 เฟรม)</span>
</button>
</div>
{/* ── Sequence Collection Selector (Isolated Library) ── */}
<div className="p-5 rounded-2xl bg-slate-900/70 border border-white/5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-white uppercase tracking-wider">
<Film className="w-4 h-4 text-[#d5ff63]" />
<span>ชุดวิดีโอเฟรมที่เลือกใช้งาน (Frame Sequence Collection)</span>
</div>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="px-4 py-1.5 rounded-xl bg-[#d5ff63] text-black font-bold text-xs hover:bg-[#c2ed4e] transition-all flex items-center gap-1.5 cursor-pointer shadow-md"
>
<Film className="w-3.5 h-3.5" />
<span>เลือก / อัปโหลดชุด ZIP ใหม่</span>
</button>
</div>
{/* Selected Sequence Card */}
<div className="p-4 rounded-xl bg-black/50 border border-white/10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-20 aspect-video rounded-lg bg-slate-800 overflow-hidden border border-white/10 shrink-0">
<img
src={`${folderPath}/frame-001.webp`}
alt="Sequence Poster"
className="w-full h-full object-contain p-1"
onError={(e) => {
// Fallback if not yet loaded
(e.target as HTMLImageElement).src = '/images/3d/sprocket-detail.png'
}}
/>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">
{folderPath.split('/').pop()?.replace(/[-_]/g, ' ').toUpperCase() || 'FRAME SEQUENCE'}
</span>
<span className="px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] font-mono flex items-center gap-1">
<CheckCircle className="w-2.5 h-2.5" /> พร้อมใช้งาน
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400 font-mono">
<span>{totalFrames} FRAMES</span>
<span>•</span>
<span>{fps} FPS</span>
<span>•</span>
<span>{durationSeconds} วินาที</span>
</div>
<p className="text-[10px] text-slate-500 font-mono">
โฟลเดอร์: {folderPath} (รูปแบบ: {pattern})
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="text-xs text-[#d5ff63] hover:underline font-mono"
>
เปลี่ยนชุด &rarr;
</button>
</div>
</div>
{/* ── 1-Click Product Linking ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-slate-200 uppercase tracking-wider">
<Package className="w-4 h-4 text-[#d5ff63]" />
<span>ผูกกับสินค้าในระบบ (1-Click Bind Product)</span>
</div>
{selectedProductId && (
<span className="text-[10px] text-[#d5ff63] font-mono">
Product ID: #{selectedProductId}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<select
value={selectedProductId}
onChange={(e) => handleSelectProduct(e.target.value)}
disabled={loadingProducts}
className="admin-input w-full text-xs"
>
<option value="">-- ไม่ระบุ (ใช้เป็นหน้า Brand/Product Story อิสระ) --</option>
{productsList.map((p) => {
const nameStr = typeof p.name === 'string' ? p.name : p.name?.th || p.name?.en || 'Unnamed'
return (
<option key={p.id} value={p.id}>
#{p.id} - {nameStr}
</option>
)
})}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={endCtaLink}
onChange={(e) => updatePayloadValue('endCtaLink', e.target.value)}
placeholder="ลิงก์ปลายทางเมื่อกดสั่งซื้อ เช่น /products/chain-sprocket-p1"
className="admin-input flex-1 text-xs"
/>
{endCtaLink && (
<a
href={endCtaLink}
target="_blank"
rel="noreferrer"
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
{/* ── Headline & Header Texts ── */}
<div className="p-4 rounded-2xl bg-slate-900/60 border border-white/5 space-y-4">
<div className="text-xs font-bold text-slate-200 uppercase tracking-wider">
ข้อความหัวเรื่อง (Header & Story Intro)
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หมวด/ป้ายกำกับ (Eyebrow Tag)
</label>
<input
type="text"
value={eyebrow}
onChange={(e) => updatePayloadValue('eyebrow', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="FROM VIDEO TO INTERACTIVE"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ป้ายซีรีส์ (Badge / Tag)
</label>
<input
type="text"
value={productBadge}
onChange={(e) => updatePayloadValue('productBadge', e.target.value)}
className="admin-input w-full text-xs font-mono"
placeholder="18 SEC / 432 FRAMES"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ชื่อสินค้า (Product Name)
</label>
<input
type="text"
value={productName}
onChange={(e) => updatePayloadValue('productName', e.target.value)}
className="admin-input w-full text-xs"
placeholder="SPROCKET / FRAME SCRUBBER"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
คำแนะนำการเลื่อน (Instruction Cue)
</label>
<input
type="text"
value={instruction}
onChange={(e) => updatePayloadValue('instruction', e.target.value)}
className="admin-input w-full text-xs"
placeholder="ลากซ้าย–ขวา หรือเลื่อนหน้าเว็บ"
/>
</div>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หัวเรื่องหลัก (Main Title)
</label>
<input
type="text"
value={title}
onChange={(e) => updatePayloadValue('title', e.target.value)}
className="admin-input w-full text-xs"
placeholder="หนึ่งวิดีโอ หมุนดูได้ทีละเฟรม"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
คำบรรยายเกริ่นนำ (Intro Subtitle)
</label>
<textarea
value={subtitle}
onChange={(e) => updatePayloadValue('subtitle', e.target.value)}
rows={2}
className="admin-input w-full text-xs"
placeholder="รวมวิดีโอ 18 วินาทีเป็น 432 เฟรม ลากภาพหรือเลื่อนหน้าเว็บเพื่อหมุนต่อ แล้วซูมดูฟันสเตอร์"
/>
</div>
</div>
{/* ── Theme, Accent, and Conversion CTA ── */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 rounded-2xl bg-slate-900/60 border border-white/5">
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
สี Accent Highlight
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={accentColor}
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
className="w-8 h-8 rounded border-0 cursor-pointer bg-transparent"
/>
<input
type="text"
value={accentColor}
onChange={(e) => updatePayloadValue('accentColor', e.target.value)}
className="admin-input flex-1 text-xs font-mono"
/>
</div>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
โทนพื้นหลัง (Theme)
</label>
<select
value={theme}
onChange={(e) => updatePayloadValue('theme', e.target.value)}
className="admin-input w-full text-xs"
>
<option value="dark">Dark Theater (#0e0e0d - แนะนำ)</option>
<option value="light">Light Studio (#f8f9fa)</option>
</select>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
หัวข้อแบนเนอร์สั่งซื้อ (CTA Headline)
</label>
<input
type="text"
value={endCtaTitle}
onChange={(e) => updatePayloadValue('endCtaTitle', e.target.value)}
className="admin-input w-full text-xs"
placeholder="ทุกมุม อยู่ในมือคุณ"
/>
</div>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">
ข้อความปุ่มสั่งซื้อ (CTA Button)
</label>
<input
type="text"
value={endCtaButtonText}
onChange={(e) => updatePayloadValue('endCtaButtonText', e.target.value)}
className="admin-input w-full text-xs"
placeholder="สั่งซื้อสินค้าตอนนี้"
/>
</div>
</div>
{/* Modal for Frame Sequence Collection */}
<FrameSequenceModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelectSequence={handleSequenceSelected}
selectedSlug={folderPath.split('/').pop()}
/>
</div>
)
}
@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
X,
Receipt,
DollarSign,
Wallet,
BadgePercent,
Coins,
Package,
Truck,
ExternalLink,
CheckCircle2,
AlertTriangle,
Copy,
Check,
TrendingUp,
} from 'lucide-react';
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
function fmt(val: number | null | undefined) {
if (val == null) return '0.00';
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
}
interface TiktokFeeBreakdownModalProps {
order: any;
onClose: () => void;
}
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// Keyboard navigation & body scroll lock
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
// Prevent background page from scrolling
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = originalOverflow;
};
}, [onClose]);
if (!order || typeof document === 'undefined') return null;
const settlement = order.settlement;
const isSettled = order.isSettled || !!settlement;
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
const orderFee = Math.abs(settlement?.orderFee ?? 0);
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
// Total deductions
const rawTotalFee = settlement?.totalFee != null
? Math.abs(settlement.totalFee)
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
// Net payout
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
// BOM & Net Profit
const totalBomCost = order.orderTotalBomCost || 0;
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
const realNetProfit = netSettlement - totalBomCost;
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
const realNetMargin = marginOnSales;
const handleCopyOrderId = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(order.orderId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
onClick={onClose}
>
<div
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
<Receipt className="w-5 h-5 text-violet-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-white text-base">
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
</h3>
{isSettled ? (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
</span>
) : (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
<button
type="button"
onClick={handleCopyOrderId}
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
</button>
<span>•</span>
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
{order.buyerUsername && (
<>
<span>•</span>
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
</>
)}
{order.createdTime && (
<>
<span>•</span>
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
</>
)}
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="p-6 space-y-5 overflow-y-auto">
{/* Pending Settlement Notice if not settled */}
{!settlement && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="font-bold text-amber-200">
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
</p>
<p className="text-amber-300/80 leading-relaxed text-[11px]">
{t(
'tiktokOrders.settlementPendingDesc',
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
)}
</p>
</div>
</div>
)}
{/* 5 Financial Summary Cards (Compact Banner) */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
{/* 1. Revenue */}
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-slate-400 text-[11px]">
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
</span>
</div>
{/* 2. TikTok Deductions */}
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-rose-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
<span className="text-[10px] text-rose-400/80 truncate">
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
</span>
</div>
{/* 3. Net Payout */}
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
<span className="text-[10px] text-cyan-400/80 truncate">
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
</span>
</div>
{/* 4. Product Cost (BOM) */}
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-amber-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
<span className="text-[10px] text-amber-400/80 truncate">
{hasMissingBom
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
: grossRevenue > 0
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
: 'BOM ครบ 100%'}
</span>
</div>
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
}`}>
<div className="flex items-center justify-between text-[11px]">
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
</span>
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
</div>
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</p>
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ขาย {marginOnSales.toFixed(1)}%
</span>
<span className="text-slate-500">•</span>
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
</span>
</div>
</div>
</div>
{/* 2-Column Wide Landscape Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
<div className="flex items-center gap-2">
<BadgePercent className="w-4 h-4 text-violet-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
</h4>
</div>
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
</div>
<div className="space-y-2.5 text-xs">
{/* Commission Fee */}
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
<div>
<p className="font-bold text-violet-200">
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
</p>
<p className="text-[10px] text-violet-300/70">
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
</p>
</div>
</div>
<span className="font-mono font-bold text-violet-400 text-sm">
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Creator Commission */}
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
<div>
<p className="font-bold text-cyan-200">
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
</p>
<p className="text-[10px] text-cyan-300/70">
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
</p>
</div>
</div>
<span className="font-mono font-bold text-cyan-400 text-sm">
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Partner Fee (TAP) */}
{(affiliatePartnerFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
</div>
<span className="font-mono text-cyan-300 font-semibold">
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Order / Transaction Fee */}
{(orderFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
</div>
<span className="font-mono text-amber-400 font-semibold">
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Growth Support Fee */}
{growthSupportFee > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-400" />
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
</div>
<span className="font-mono text-blue-400 font-semibold">
-฿{fmt(growthSupportFee)}
</span>
</div>
)}
{/* Shipping Breakdown */}
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
<div className="flex items-center justify-between text-slate-300 text-xs">
<div className="flex items-center gap-2">
<Truck className="w-3.5 h-3.5 text-blue-400" />
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
</div>
<span className="font-mono font-semibold text-slate-200">
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
</span>
</div>
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
<span>
{t('tiktokOrders.shippingBreakdownDetail', {
actual: fmt(shippingFeeActual),
subsidy: fmt(platformShippingDiscount),
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
})}
</span>
</div>
</div>
)}
</div>
</div>
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
<div className="lg:col-span-5 space-y-4">
{/* BOM Profitability Analysis Card */}
<div
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
}`}
>
<div className="flex items-center justify-between pb-2 border-b border-white/10">
<div className="flex items-center gap-2">
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
</h4>
</div>
{hasMissingBom ? (
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
</span>
) : (
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
</span>
)}
</div>
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center text-slate-300">
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
</div>
<div className="flex justify-between items-center text-amber-300">
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
<span className="font-mono font-bold">
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
</span>
</div>
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
<div>
<p className="font-bold text-white text-sm">
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
</p>
<p className="text-[10px] text-slate-400">
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
</p>
</div>
<div className="text-right">
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</span>
</div>
</div>
</div>
</div>
{/* Order Items Snippet */}
{order.items && order.items.length > 0 && (
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
<div className="flex items-center gap-1.5">
<Package className="w-3.5 h-3.5 text-slate-400" />
<span>
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
</span>
</div>
</div>
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
{order.items.map((item: any, idx: number) => (
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
<p className="text-[11px] text-slate-400 font-mono">
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
</p>
</div>
<div className="text-right shrink-0 font-mono">
<p className="text-white font-bold">
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
</p>
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
<Link
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
>
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
<button
onClick={onClose}
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
>
{t('tiktokOrders.closeModal', 'ปิด')}
</button>
</div>
</div>
</div>,
document.body
);
}
+4 -4
View File
@@ -116,18 +116,18 @@
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
to { opacity: 1; transform: none; }
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
to { opacity: 1; transform: none; }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
animation: fadeIn 0.4s ease-out;
}
.animate-zoom-in {
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "Net Profit",
"thActions": "Actions",
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
"settlementCompleteBadge": "Settled & Transferred",
"settlementPendingBadge": "Pending Settlement",
"orderRevenueLabel": "Product Revenue",
"tiktokDeductionsLabel": "Total TikTok Deductions",
"netPayoutLabel": "Net Payout",
"cardBomCost": "Product Cost (BOM)",
"cardNetProfit": "Net Profit",
"profitOnSales": "Profit/Sales",
"profitOnCost": "Profit/Cost",
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
"tiktokCommissionFee": "TikTok Shop Commission Fee",
"tiktokCommissionDesc": "Platform category commission rate deduction",
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
"orderFee": "Order / Transaction Fee",
"growthSupportFee": "Growth Support Fee",
"shippingBreakdown": "Shipping & Logistics Fees",
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
"shippingFreeForSeller": "Seller shipping covered",
"sellerDiscountLabel": "Seller Discount",
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
"totalBomCostLabel": "Deduct: BOM Material Cost",
"realNetProfitLabel": "Real Net Profit",
"realNetMarginLabel": "Net Margin",
"orderItemsTitle": "Items in this Order",
"viewFullOrderDetail": "View Full Order Detail",
"closeModal": "Close",
"copyOrderId": "Copy Order ID",
"copied": "Copied",
"processingData": "Processing data...",
"noOrders": "No data available",
"settledTransferred": "Settled ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "純利益",
"thActions": "操作",
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
"settlementCompleteBadge": "振込完了 (Settled)",
"settlementPendingBadge": "精算待ち",
"orderRevenueLabel": "商品売上高",
"tiktokDeductionsLabel": "TikTok 控除合計",
"netPayoutLabel": "振込受取額 (Net Payout)",
"cardBomCost": "商品原価 (BOM)",
"cardNetProfit": "純利益",
"profitOnSales": "売上利益率",
"profitOnCost": "原価利益率",
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
"affiliatePartnerFee": "パートナー手数料 (TAP)",
"orderFee": "注文・決済処理手数料 (Order Fee)",
"growthSupportFee": "成長支援手数料 (Growth Support)",
"shippingBreakdown": "配送・物流手数料内訳",
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
"shippingFreeForSeller": "ショップ負担なし",
"sellerDiscountLabel": "ショップ割引",
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
"totalBomCostLabel": "控除:BOM原料および包材原価",
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
"realNetMarginLabel": "純利益率",
"orderItemsTitle": "注文内商品一覧",
"viewFullOrderDetail": "注文詳細画面を開く",
"closeModal": "閉じる",
"copyOrderId": "注文番号をコピー",
"copied": "コピー完了",
"processingData": "データを処理中...",
"noOrders": "データがありません",
"settledTransferred": "振込済 ฿{{amount}}",
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "กำไรสุทธิ",
"thActions": "การกระทำ",
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
"settlementCompleteBadge": "โอนเงินแล้ว",
"settlementPendingBadge": "รอ Settlement",
"orderRevenueLabel": "ยอดขายสินค้า",
"tiktokDeductionsLabel": "TikTok หักรวม",
"netPayoutLabel": "ยอดโอนสุทธิ",
"cardBomCost": "ต้นทุนสินค้า (BOM)",
"cardNetProfit": "กำไรสุทธิ",
"profitOnSales": "กำไร/ยอดขาย",
"profitOnCost": "กำไร/ทุน",
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
"closeModal": "ปิด",
"copyOrderId": "คัดลอก Order ID",
"copied": "คัดลอกแล้ว",
"processingData": "กำลังประมวลผลข้อมูล...",
"noOrders": "ยังไม่มีข้อมูล",
"settledTransferred": "โอนแล้ว ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "净利润",
"thActions": "操作",
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
"feeModalTitle": "TikTok 费用与结算明细",
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
"settlementCompleteBadge": "已完成打款",
"settlementPendingBadge": "等待结算",
"orderRevenueLabel": "商品销售额",
"tiktokDeductionsLabel": "TikTok 扣款合计",
"netPayoutLabel": "最终打款金额",
"cardBomCost": "商品成本 (BOM)",
"cardNetProfit": "净利润",
"profitOnSales": "利润率(销售)",
"profitOnCost": "利润率(成本)",
"feeBreakdownTitle": "TikTok 平台扣款明细",
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
"tiktokCommissionDesc": "按平台品类佣金率扣除",
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
"affiliateCommissionDesc": "支付给带货达人的佣金",
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
"orderFee": "订单/交易手续费 (Order Fee)",
"growthSupportFee": "成长支持费 (Growth Support Fee)",
"shippingBreakdown": "物流运费明细",
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
"shippingFreeForSeller": "商家免运费",
"sellerDiscountLabel": "商家折扣",
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
"realNetMarginLabel": "净利润率",
"orderItemsTitle": "订单内商品列表",
"viewFullOrderDetail": "查看订单完整详情",
"closeModal": "关闭",
"copyOrderId": "复制订单号",
"copied": "已复制",
"processingData": "正在处理数据...",
"noOrders": "暂无数据",
"settledTransferred": "已打款 ฿{{amount}}",
+22
View File
@@ -33,6 +33,8 @@ import { getAuthHeaders } from '../utils/auth';
import CinematicStoryScrollForm from '../components/page-builder/CinematicStoryScrollForm';
import AppleShowcaseForm from '../components/page-builder/AppleShowcaseForm';
import Interactive360ShowcaseForm from '../components/page-builder/Interactive360ShowcaseForm';
import VdoFrameScrubberForm from '../components/page-builder/VdoFrameScrubberForm';
import HeroForm from '../components/page-builder/HeroForm';
import AnnouncementBarForm from '../components/page-builder/AnnouncementBarForm';
import NewsletterForm from '../components/page-builder/NewsletterForm';
@@ -1469,6 +1471,8 @@ export default function PageBuilderPage() {
onChange={(e) => handleTypeChange(e.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="VDO_FRAME_SCRUBBER">🎬 VDO FRAME SCRUBBER (432 Frames Video Scrub)</option>
<option value="INTERACTIVE_360_SHOWCASE">⚡ INTERACTIVE 360° TURNTABLE (3D Web Sprocket)</option>
<option value="CINEMATIC_STORY_SCROLL">✨ CINEMATIC STORY SCROLL (Tobaki Scrollytelling)</option>
<option value="APPLE_SHOWCASE">✨ APPLE KEYNOTE SHOWCASE (Apple Style Tour)</option>
<option value="HERO">HERO (Campaign Intro)</option>
@@ -1593,6 +1597,24 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'VDO_FRAME_SCRUBBER' && (
<VdoFrameScrubberForm
payload={editingSection.payload || {}}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'INTERACTIVE_360_SHOWCASE' && (
<Interactive360ShowcaseForm
payload={editingSection.payload || {}}
updatePayloadValue={updatePayloadValue}
onBrowseMedia={(cb) => {
setCustomMediaCallback(() => cb);
setIsMediaModalOpen(true);
}}
/>
)}
{editingSection.type?.toUpperCase() === 'CINEMATIC_STORY_SCROLL' && (
<CinematicStoryScrollForm
payload={editingSection.payload || {}}
+58 -4
View File
@@ -11,9 +11,13 @@ import {
ArrowLeft,
Coins,
Layers,
Calculator
Calculator,
Bot,
FileJson
} from 'lucide-react'
import { API_BASE, type Product } from '@homaroma/types'
import ProductBomPromptModal from '../components/ProductBomPromptModal'
import ProductBomImportModal from '../components/ProductBomImportModal'
function fmtMoney(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00'
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const handleApplyBomJson = (updatedVariants: any[]) => {
setVariants(updatedVariants)
setSuccess(true)
setTimeout(() => setSuccess(false), 3500)
}
useEffect(() => {
const fetchData = async () => {
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
}
const productName =
typeof product.name === 'string'
? product.name
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
(typeof product.name === 'string' && product.name.trim())
? product.name.trim()
: (product.name as any)?.th ||
(product.name as any)?.en ||
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
((product as any).brand?.name
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
: `Product #${product.id}`)
// Summary Metrics Across All Variants
const totalVariantsCount = variants.length
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsPromptModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
>
<Bot className="w-4 h-4 text-purple-400" />
<span>Get Prompt</span>
</button>
<button
type="button"
onClick={() => setIsImportModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import BOM JSON</span>
</button>
<button
onClick={() => navigate('/products')}
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
})
)}
</div>
{/* LLM Prompt Generator Modal */}
<ProductBomPromptModal
isOpen={isPromptModalOpen}
onClose={() => setIsPromptModalOpen(false)}
productId={id || ''}
productName={productName}
variants={variants}
rawMaterials={rawMaterials}
/>
{/* LLM JSON Import Modal */}
<ProductBomImportModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
variants={variants}
rawMaterials={rawMaterials}
onApply={handleApplyBomJson}
/>
</div>
)
}
@@ -0,0 +1,431 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
Save,
Trash2,
Loader2,
PackageOpen,
Boxes,
ExternalLink,
Layers,
Sparkles,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
const COMMON_UNITS = [
{ label: 'ชิ้น (piece)', value: 'piece' },
{ label: 'กก. (kg)', value: 'kg' },
{ label: 'กรัม (g)', value: 'g' },
{ label: 'ลิตร (liter)', value: 'liter' },
{ label: 'มล. (ml)', value: 'ml' },
{ label: 'แพ็ค (pack)', value: 'pack' },
{ label: 'กล่อง (box)', value: 'box' },
{ label: 'หลอด (tube)', value: 'tube' },
];
export default function RawMaterialDetailPage() {
const { id } = useParams<{ id: string }>();
const isNew = !id || id === 'new';
const navigate = useNavigate();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form Fields
const [name, setName] = useState('');
const [unit, setUnit] = useState('piece');
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
const [materialData, setMaterialData] = useState<any>(null);
useEffect(() => {
if (isNew) return;
setLoading(true);
fetch(`${API_BASE}/raw-materials/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
return await res.json();
})
.then((data) => {
setMaterialData(data);
setName(data.name || '');
setUnit(data.unit || 'piece');
setPricePerUnit(Number(data.pricePerUnit) || 0);
})
.catch((err) => {
console.error(err);
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
})
.finally(() => setLoading(false));
}, [id, isNew]);
const showToast = (type: 'success' | 'error', text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 3000);
};
const handleSave = async (e?: FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
return;
}
if (!unit.trim()) {
showToast('error', 'กรุณาระบุหน่วยนับ');
return;
}
if (pricePerUnit < 0) {
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
unit: unit.trim(),
pricePerUnit: Number(pricePerUnit) || 0,
};
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
const method = isNew ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
}
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
if (isNew && data.id) {
setTimeout(() => {
navigate(`/raw-materials/${data.id}`, { replace: true });
}, 800);
}
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setDeleting(true);
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
navigate('/raw-materials', { replace: true });
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
</div>
);
}
const bomItems = materialData?.bomItems || [];
return (
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
{/* Toast Notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
toast.type === 'success'
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
}`}
>
{toast.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 text-rose-400" />
)}
<span className="text-sm font-medium">{toast.text}</span>
</div>
)}
{/* Top Breadcrumb & Navigation */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
<PackageOpen className="w-3.5 h-3.5" />
<span>Raw Materials</span>
</Link>
<span>/</span>
<span className="text-cyan-400 font-medium">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/raw-materials')}
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
title="ย้อนกลับไปหน้ารายการ"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
{!isNew && (
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
ID: #{id}
</span>
)}
</h1>
<p className="text-xs md:text-sm text-slate-400">
{isNew
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{!isNew && (
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span>ลบวัตถุดิบ</span>
</button>
)}
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Form Details (2 cols) */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Sparkles className="w-4 h-4 text-cyan-400" />
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
ข้อมูลวัตถุดิบ (Material Information)
</h2>
</div>
{/* Material Name */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
className="admin-input w-full text-sm"
/>
</div>
{/* Unit Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
</label>
<input
type="text"
required
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="เช่น piece, kg, liter, pack, ml"
className="admin-input w-full text-sm"
/>
{/* Quick Preset Unit Chips */}
<div className="space-y-1.5">
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
<div className="flex flex-wrap gap-2">
{COMMON_UNITS.map((u) => (
<button
key={u.value}
type="button"
onClick={() => setUnit(u.value)}
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
unit === u.value
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{u.label}
</button>
))}
</div>
</div>
</div>
{/* Cost Price per Unit */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
฿
</span>
<input
type="number"
step="0.01"
min="0"
required
value={pricePerUnit === 0 ? '' : pricePerUnit}
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
/>
</div>
<p className="text-[11px] text-slate-500">
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
</p>
</div>
</form>
</div>
{/* Right Column: BOM Linked Products & Summary (1 col) */}
<div className="space-y-6">
{/* Summary Card */}
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สรุปภาพรวม (Overview)
</h3>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">สถานะ:</span>
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">หน่วยนับ:</span>
<span className="font-mono font-bold text-white">{unit || '-'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
<span className="font-mono font-bold text-emerald-400">
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
{!isNew && materialData?.updatedAt && (
<div className="flex justify-between py-1.5 text-slate-300">
<span className="text-slate-400">อัปเดตล่าสุด:</span>
<span className="text-slate-400">
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
</span>
</div>
)}
</div>
</div>
{/* BOM Usage Section */}
{!isNew && (
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
</h3>
</div>
<Link
to="/products/bom"
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
>
<span>จัดการ BOM</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{bomItems.length === 0 ? (
<div className="py-6 text-center space-y-2">
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
<p className="text-xs text-slate-400 italic">
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
</p>
<p className="text-[11px] text-slate-500">
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
</p>
</div>
) : (
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
{bomItems.map((item: any, idx: number) => {
const product = item.productVariant?.product;
const variant = item.productVariant;
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
.filter(Boolean)
.join(' / ');
return (
<div
key={item.id || idx}
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
>
<p className="text-xs font-bold text-white line-clamp-1">
{product?.name || `Product #${variant?.productId}`}
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
<span className="font-mono text-cyan-400 font-bold">
{item.quantity} {unit}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+339 -171
View File
@@ -1,227 +1,395 @@
import { useState, useEffect } from 'react'
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
import { API_BASE, type RawMaterial } from '@homaroma/types'
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Plus,
Edit3,
Trash2,
PackageOpen,
Search,
FileJson,
Boxes,
Layers,
Sparkles,
Check,
X,
Loader2,
Pencil,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
export default function RawMaterialsPage() {
const [materials, setMaterials] = useState<RawMaterial[]>([])
const [loading, setLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
const [saving, setSaving] = useState(false)
const navigate = useNavigate();
const [materials, setMaterials] = useState<RawMaterial[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
// Inline Price Editing State
const [editingId, setEditingId] = useState<number | null>(null);
const [editingPrice, setEditingPrice] = useState<string>('');
const [savingId, setSavingId] = useState<number | null>(null);
const startEditing = (item: RawMaterial) => {
setEditingId(item.id);
setEditingPrice(String(item.pricePerUnit ?? ''));
};
const cancelEditing = () => {
setEditingId(null);
setEditingPrice('');
};
const handleSavePrice = async (item: RawMaterial) => {
const val = parseFloat(editingPrice);
if (isNaN(val) || val < 0) {
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
return;
}
setSavingId(item.id);
try {
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pricePerUnit: val,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
}
setMaterials((prev) =>
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
);
setEditingId(null);
setEditingPrice('');
} catch (err: any) {
console.error(err);
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
} finally {
setSavingId(null);
}
};
const fetchMaterials = async () => {
setLoading(true)
setLoading(true);
try {
const res = await fetch(`${API_BASE}/raw-materials`)
const res = await fetch(`${API_BASE}/raw-materials`);
if (res.ok) {
setMaterials(await res.json())
setMaterials(await res.json());
}
} catch (err) {
console.error('Failed to fetch raw materials', err)
console.error('Failed to fetch raw materials', err);
} finally {
setLoading(false)
setLoading(false);
}
}
};
useEffect(() => {
fetchMaterials()
}, [])
fetchMaterials();
}, []);
const handleOpenModal = (material?: RawMaterial) => {
if (material) {
setEditingId(material.id)
setName(material.name)
setUnit(material.unit)
setPricePerUnit(material.pricePerUnit)
} else {
setEditingId(null)
setName('')
setUnit('')
setPricePerUnit(0)
}
setIsModalOpen(true)
}
const handleDelete = async (id: number, name: string) => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
const handleSave = async () => {
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
setSaving(true)
try {
const payload = { name, unit, pricePerUnit }
const url = editingId
? `${API_BASE}/raw-materials/${editingId}`
: `${API_BASE}/raw-materials`
const res = await fetch(url, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
if (res.ok) {
setIsModalOpen(false)
fetchMaterials()
fetchMaterials();
} else {
alert('Failed to save raw material')
const data = await res.json();
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
} catch (err) {
console.error(err)
} finally {
setSaving(false)
console.error(err);
}
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this raw material?')) return
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
if (res.ok) {
fetchMaterials()
}
} catch (err) {
console.error(err)
}
}
// Filtered materials by search query
const filteredMaterials = useMemo(() => {
if (!searchQuery.trim()) return materials;
const q = searchQuery.toLowerCase().trim();
return materials.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.unit.toLowerCase().includes(q) ||
String(m.id).includes(q)
);
}, [materials, searchQuery]);
// Aggregate metrics
const metrics = useMemo(() => {
const totalCount = materials.length;
const avgCost = totalCount > 0
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
: 0;
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
return { totalCount, avgCost, units };
}, [materials]);
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-8 animate-fade-in pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{materials.length} รายการ
</span>
</div>
<p className="text-slate-400 text-sm">
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
</p>
</div>
<div className="flex items-center gap-3">
{/* Bulk JSON Import / Export Button */}
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import / Export JSON</span>
</button>
{/* Add Material (Navigates to Full Page Route) */}
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
<Plus className="w-4 h-4" />
<span>เพิ่มวัตถุดิบ</span>
</button>
</div>
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Material
</button>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<PackageOpen className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
<Sparkles className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
className="admin-input w-full pl-10 text-xs"
/>
</div>
<div className="text-xs text-slate-400">
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
</div>
</div>
{/* Data Table */}
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.03]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
<td colSpan={4} className="px-6 py-8">
<td colSpan={5} className="px-6 py-8">
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
</td>
</tr>
))
) : materials.length === 0 ? (
) : filteredMaterials.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-20 text-center space-y-3">
<td colSpan={5} className="px-6 py-20 text-center space-y-3">
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
</td>
</tr>
) : materials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
<td className="px-6 py-4">
<p className="text-sm font-bold text-white">{item.name}</p>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider">
{item.unit}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
<Edit3 className="w-4 h-4" />
<p className="text-slate-400 font-medium">
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
>
นำเข้าผ่าน JSON
</button>
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
<Trash2 className="w-4 h-4" />
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary text-xs px-4 py-2"
>
เพิ่มวัตถุดิบใหม่
</button>
</div>
</td>
</tr>
))}
) : (
filteredMaterials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
<td className="px-6 py-4 text-xs font-mono text-slate-500">
#{item.id}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<Boxes className="w-4 h-4" />
</div>
<Link
to={`/raw-materials/${item.id}`}
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
>
{item.name}
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
{item.unit}
</span>
</td>
<td className="px-6 py-3">
{editingId === item.id ? (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
฿
</span>
<input
type="number"
step="0.01"
min="0"
autoFocus
value={editingPrice}
onChange={(e) => setEditingPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePrice(item);
if (e.key === 'Escape') cancelEditing();
}}
disabled={savingId === item.id}
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
placeholder="0.00"
/>
</div>
<button
type="button"
disabled={savingId === item.id}
onClick={() => handleSavePrice(item)}
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
title="บันทึกราคา (Enter)"
>
{savingId === item.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
disabled={savingId === item.id}
onClick={cancelEditing}
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
title="ยกเลิก (Esc)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div
onClick={() => startEditing(item)}
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
>
<p className="text-sm font-bold text-emerald-400 font-mono">
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
</div>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Edit Button navigates to Full Page Route */}
<button
type="button"
onClick={() => navigate(`/raw-materials/${item.id}`)}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
title="แก้ไขรายละเอียด"
>
<Edit3 className="w-3.5 h-3.5" />
<span>แก้ไข</span>
</button>
<button
type="button"
onClick={() => handleDelete(item.id, item.name)}
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
title="ลบรายการ"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6">
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="admin-input w-full"
placeholder="e.g. Alcohol, Glass Bottle 50ml"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
<input
type="text"
value={unit}
onChange={e => setUnit(e.target.value)}
className="admin-input w-full"
placeholder="e.g. kg, liter, piece"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
<input
type="number"
value={pricePerUnit || ''}
onChange={e => setPricePerUnit(Number(e.target.value))}
className="admin-input w-full"
placeholder="0.00"
step="0.01"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
<button
onClick={() => setIsModalOpen(false)}
disabled={saving}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-6 py-2 flex items-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save Material'}
</button>
</div>
</div>
</div>
)}
{/* Bulk JSON Import / Export Modal */}
<RawMaterialBulkJsonModal
isOpen={isBulkModalOpen}
onClose={() => setIsBulkModalOpen(false)}
materials={materials}
onSuccess={fetchMaterials}
/>
</div>
)
);
}
+33 -8
View File
@@ -21,6 +21,7 @@ import {
import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
function authHeaders() {
return {
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showFeeModal, setShowFeeModal] = useState(false);
useEffect(() => {
if (!orderId) return;
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
</span>
</div>
<div className="flex justify-between items-center text-violet-300">
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.commissionSummary')}
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
</span>
</div>
</button>
<div className="flex justify-between items-center text-cyan-300">
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.affiliateSummary')}
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
</span>
</div>
</button>
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div>
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
</div>
</div>
</div>
{showFeeModal && (
<TiktokFeeBreakdownModal
order={order}
onClose={() => setShowFeeModal(false)}
/>
)}
</div>
);
}
+61 -13
View File
@@ -7,10 +7,11 @@ import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
BadgePercent, Wallet, Receipt
BadgePercent, Wallet, Receipt, Info
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
// ── Shop Config ───────────────────────────────────────────────────────────────
export const TIKTOK_SHOPS = [
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [bomFilter, setBomFilter] = useState('all');
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
<th
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thCommission')}
<Info className="w-3 h-3 text-violet-400/50" />
</span>
</th>
<th
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thAffiliate')}
<Info className="w-3 h-3 text-cyan-400/50" />
</span>
</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
<th className="px-4 py-3.5 text-center" />
</tr>
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Commission (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.commissionFee > 0 ? (
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
>
{order.commissionFee > 0 ? (
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
</button>
</td>
{/* Affiliate (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.affiliateFee > 0 ? (
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
>
{order.affiliateFee > 0 ? (
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
</button>
</td>
{/* Profit */}
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
</tbody>
</table>
</div>
{/* Fee Breakdown Modal */}
{selectedFeeOrder && (
<TiktokFeeBreakdownModal
order={selectedFeeOrder}
onClose={() => setSelectedFeeOrder(null)}
/>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
import { ICON_REGISTRY } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
import MediaLibraryModal from '../../components/MediaLibraryModal'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return (
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
description: string;
}
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th');
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
['ctaLabel', 'CTA label', 'View details'],
];
const isEnabled = featured.enabled !== false;
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div>
<div className="text-xs font-bold text-white">Enable Featured Card</div>
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
</div>
<button
type="button"
onClick={() => updateFeatured('enabled', !isEnabled)}
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
isEnabled
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
</button>
</div>
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
{[
['content', 'Content'],
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
</div>
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
<div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{featured.imageUrl ? (
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
<>
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
<ImageIcon className="w-6 h-6 text-cyan-glow" />
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
</div>
</>
) : (
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
<div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
<ImageIcon className="w-8 h-8" />
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
</div>
)}
</div>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
{featured.imageUrl && (
<button
type="button"
onClick={() => updateFeatured('imageUrl', null)}
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
</div>
{featured.imageUrl && (
<div className="pt-2">
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
);
}
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
if (!node) {
return (
<div className="admin-card p-6 space-y-3">
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
<FeaturedCardEditorFields
featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
onOpenMediaPicker={onOpenMediaPicker}
/>
</div>
);
}
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
const childrenKey = type === 'root' ? 'columns' : 'links';
const labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column';
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
{/* Image URL field */}
<div className="flex items-start gap-3">
{/* Preview thumbnail */}
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center">
<div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{imageValue ? (
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<>
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
</div>
</>
) : (
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span>
<div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
<ImageIcon className="w-4 h-4 mb-0.5" />
<span className="text-[8px] text-center leading-tight">Library</span>
</div>
)}
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={imageValue}
placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/>
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={imageValue}
placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/>
{imageValue && (
<button
type="button"
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
{type !== 'root' && (
<IconSelect
value={node.icon}
@@ -325,30 +420,193 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div>
{type === 'root' && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
<div className="space-y-3">
{/* Layout Mode Selector */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
Sub-Menu Layout Preset
</label>
<span className="text-[10px] text-cyan-glow font-medium">
{node.layout === 'classic'
? 'Classic Dropdown'
: node.layout === 'tabbed'
? 'Tabbed Hierarchy'
: node.layout === 'visual-cards'
? 'Visual Cards'
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
type="button"
onClick={() => onChange({ ...node, layout: 'columns' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
(!node.layout || node.layout === 'columns')
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'tabbed' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'tabbed'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'visual-cards'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'classic' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'classic'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
</div>
</button>
</div>
{/* Submenu Layout Modifier / Description */}
{(!node.layout || node.layout === 'columns') && (
<div className="pt-3 border-t border-white/5 space-y-3">
{/* Column Count */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
{[2, 3, 4].map((count) => {
const currentCols = node.layoutConfig?.columnCount || 2;
return (
<button
key={count}
type="button"
onClick={() => onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
})}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
currentCols === count
? 'bg-cyan-glow text-night-900'
: 'text-slate-400 hover:text-white'
}`}
>
{count} Cols
</button>
);
})}
</div>
</div>
{/* Toggle Featured Card */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
<button
type="button"
onClick={() => {
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
const nextState = !isCurrentlyShown;
onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
featured: { ...featured, enabled: nextState }
});
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
</button>
</div>
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featured.enabled === false ? 'Disabled' : featuredTitle}
</p>
)}
{node.layout === 'tabbed' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
</div>
)}
{node.layout === 'visual-cards' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
</div>
)}
{node.layout === 'classic' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
</div>
)}
</div>
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featuredTitle}
</p>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
)}
</div>
)}
</div>
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
depth={depth + 1}
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
onRemove={() => handleRemoveChild(i)}
onOpenMediaPicker={onOpenMediaPicker}
/>
))}
</div>
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
const [pastedJsonText, setPastedJsonText] = useState('')
const [pasteError, setPasteError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
setMediaSelectCallback(() => onSelectUrl)
setIsMediaModalOpen(true)
}
useEffect(() => {
Promise.all([
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
}}
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx}
onOpenMediaPicker={handleOpenMediaPicker}
/>
))
)}
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
<FeaturedCardSidePanel
node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode}
onOpenMediaPicker={handleOpenMediaPicker}
/>
) : (
<div className="admin-card p-6 space-y-4">
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
)}
{/* Paste JSON Modal */}
{showPasteModal && (
{showPasteModal && createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* LLM Mega Menu Guide Modal */}
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
onOpenPasteJson={() => setShowPasteModal(true)}
onOpenImportFile={() => fileInputRef.current?.click()}
/>
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
onSelect={(url) => {
if (mediaSelectCallback) {
mediaSelectCallback(url)
}
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
mediaType="image"
/>
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Loaded 100 of 954 files, more files were not shown because too many files have changed in this diff. Show more