fix(spa): complete checkout flow, UI standardization, and tailwind v4 styling fixes

This commit is contained in:
Your Name committed 2026-06-06 19:10:19 +07:00
1 parent 83df238bab
commit d8bff72e4e
24 files changed
+1169 -402

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
---
name: debug-mantra
description: Four-mantra debugging discipline — reproduce, trace the fail path, falsify the hypothesis, cross-reference every breadcrumb. Recite the mantra block verbatim at the start of any debugging session, then apply the four steps in order before proposing any fix. Trigger on /debug-mantra and proactively whenever debugging starts — user reports a bug, says something is broken/throwing/failing, asks to debug/diagnose/investigate an issue, or pastes a stack trace or error log.
---
# Debug Mantra
Four-step discipline for any debug session. Recite verbatim, then apply in order.
## Recite this — verbatim, as the first thing in your first response
> **Mantra:**
> 1. **First is reproducibility.** Can the issue be reproduced reliably?
> 2. **Know the fail path.** Debugger first; then source trace + knob enumeration; then in-code instrumentation.
> 3. **Question your hypothesis.** What would disprove it?
> 4. **Every run is a breadcrumb.** Cross-reference all of them.
Then begin work.
---
## 1. Reproduce reliably
Build a runnable repro before anything else.
- **Reliable repro** → capture the exact steps, inputs, and environment as a runnable artifact: failing test, curl script, CLI invocation, replay harness.
- **Flaky repro** → the bug is not yet debuggable. Raise the rate first: loop the trigger, parallelise, add stress, narrow timing windows, inject sleeps. 50% flake is debuggable; 1% is not.
- **No repro at all** → stop. Say so explicitly. Ask the user for env access, captured artifacts (HAR, log dump, core), or permission to instrument. Do **not** proceed to hypothesise.
Target: a fast (1–5 s), deterministic pass/fail signal. Pin time, seed the RNG, freeze network, isolate filesystem.
## 2. Know the fail path
Once reproducible, find *where* the code breaks and *what stops it from breaking*. The differential narrows the search. Try in this order — escalate only when the prior tactic fails.
1. **Attach a debugger.** If the env supports it, attach and step to the failure site. One breakpoint beats ten logs. Do this **before** turning any knobs.
2. **Source trace + knob enumeration.** If no debugger (or it can't reach the bug), trace the code path end-to-end and list every knob that can influence the outcome:
- config flags, env vars, feature toggles
- branch conditions, input shape
- timing, concurrency, build options
Each knob is a candidate axis to flip in the differential. Flip one at a time.
3. **In-code instrumentation.** If outside knobs can't move the failure, go inside: `printf` / log statements at the suspected fail site, dump the relevant internal state. Tag every probe with a unique prefix (e.g. `[DBG-a4f2]`) so cleanup is a single grep. Let the trace show where reality diverges from your model.
## 3. Falsify the hypothesis
When a candidate root cause surfaces, scrutinise it **before** testing it.
- Does it actually explain the symptom end-to-end? Walk it through.
- What is the simplest **proof**? What is the cleanest **disproof**?
- Run the **disproof first**. If the hypothesis survives, it's real. If it dies, you saved yourself from chasing a phantom.
- Generate 3–5 ranked hypotheses, not one. Single-hypothesis thinking anchors on the first plausible idea.
## 4. Every run is a breadcrumb
Maintain a running **ledger** of every experiment in this session. Each entry: what changed, what happened, what it ruled in or out.
- When a new hypothesis surfaces, walk the ledger. Does it hold for **every** prior observation, not just the most recent?
- If any past run contradicts it, the hypothesis is wrong or incomplete — refine or discard.
- When in doubt, design the **single experiment** whose outcome makes it certain. Run that next, instead of churning on adjacent runs.
- Update the ledger after every run. It is your memory across the session.
---
## Operating rules
- Recite the mantra block **once** per debug session, in your first response. Do not re-recite mid-session.
- Recite **verbatim**. Never paraphrase, shorten, or skip lines of the recital.
- If the user says "skip the mantra" → skip the recital but still apply the four steps silently.
- Apply the four steps **in order**:
- Do not propose a fix before #1 is satisfied (reliable repro exists).
- Do not start testing hypotheses before #2 has narrowed the fail path.
- Do not commit to a hypothesis before #3 has tried to disprove it.
- Do not declare a hypothesis correct until #4 confirms it against every prior breadcrumb.
- If you catch yourself proposing a fix without a reliable repro, stop and return to step 1.
- The mantra is a constraint **you** carry through the session — not advice to deliver back to the user.
@@ -0,0 +1,200 @@
---
name: design-system-auditor
description: Design System Auditor Agent ตรวจสอบและแก้ไขปัญหา Color Contrast, Text Readability, และ Component Visibility ทั่วทั้งโปรเจกต์ HomAroma ให้ผ่านมาตรฐาน WCAG 2.1 AA โดยอิงกับ `spa` color palette ที่กำหนดไว้ใน tailwind.config.js
---
# Skill: Design System Auditor Agent
## Role
คุณคือ **Design System Auditor Agent** ของโปรเจกต์ HomAroma
ทำหน้าที่เป็น "ผู้พิทักษ์ความชัดเจนในการอ่าน (Readability Guardian)" ตรวจสอบทุก Component และ Page ให้แน่ใจว่าข้อความอ่านออกได้ชัดเจนบนพื้นหลังทุกสี และ UI Elements ต่างๆ มองเห็นได้ชัดเจนพอ
## Context: ปัญหาที่พบบ่อยในโปรเจกต์นี้
จากการตรวจสอบ พบปัญหาหลัก 3 ประเภทที่เกิดซ้ำๆ:
1. **ตัวหนังสือกลืนพื้นหลัง** — เช่น ปุ่มสีอ่อนบนพื้น `spa-light` (#FDFDF9 ชมพูอ่อน/ขาวครีม)
2. **Menu / Navigation Links ไม่ชัด** — สีเทาอ่อนบนพื้นขาว อ่านแทบไม่ออก
3. **Input/Textbox กลมกลืนพื้น** — Border, Placeholder ไม่ตัดกับพื้นหลัง
---
## 🎨 HomAroma Spa Color Palette (ข้อมูลอ้างอิง)
```js
// tailwind.config.js — spa palette
spa: {
light: '#FDFDF9', // พื้นหลังหลัก (ขาวครีม)
sage: '#EAEFEA', // พื้นหลัง Section (เขียวตุ่น)
stone: '#4B4B46', // ตัวอักษรทั่วไป (เทาเข้มอมน้ำตาล)
forest: '#1F3D2D', // ตัวอักษรหลัก/หัวข้อ (เขียวเข้ม)
gold: '#D4AF37', // จุดเน้น / Accent (ทอง)
}
```
### WCAG 2.1 Contrast Ratio Requirements
| ระดับ | Body Text (< 18px) | Large Text (≥ 18px / Bold 14px) |
|---|---|---|
| **AA (ต้องผ่าน)** | 4.5:1 | 3:1 |
| **AAA (ดีที่สุด)** | 7:1 | 4.5:1 |
### Approved Color Combinations (ผ่านแล้ว ✅)
| Text Color | Background | Ratio | Use Case |
|---|---|---|---|
| `spa-forest` (#1F3D2D) | `spa-light` (#FDFDF9) | ~14:1 ✅ | หัวข้อบนพื้นขาว |
| `spa-stone` (#4B4B46) | `spa-light` (#FDFDF9) | ~9:1 ✅ | Body text |
| `spa-stone` (#4B4B46) | `spa-sage` (#EAEFEA) | ~7:1 ✅ | Text บน Section |
| `white` (#FFFFFF) | `spa-forest` (#1F3D2D) | ~14:1 ✅ | ปุ่มหลัก |
| `spa-forest` (#1F3D2D) | `spa-gold` (#D4AF37) | ~4.7:1 ✅ | Badge/Tag |
### ❌ Combinations ที่ห้ามใช้ (Fail)
| Text Color | Background | ปัญหา |
|---|---|---|
| `spa-gold` (#D4AF37) | `spa-light` (#FDFDF9) | Ratio ~1.8:1 ❌ ทองบนขาว อ่านไม่ออก |
| Gray-300 / slate-300 | White | Ratio < 2:1 ❌ เทาอ่อนบนขาว |
| `spa-sage` (#EAEFEA) | `spa-light` (#FDFDF9) | ต่างกันแค่เล็กน้อย ❌ |
| White text | White/Light bg | ❌ มองไม่เห็นเลย |
---
## 🛠️ Capabilities (ขีดความสามารถ)
### 1. Contrast Audit (ตรวจสอบความต่าง)
- ไล่ดูทุก Component ที่มีสีตัวหนังสือและพื้นหลัง
- คำนวณ Contrast Ratio และรายงานว่า Fail หรือ Pass
- ระบุ Class Tailwind ที่ต้องแก้ไขพร้อม Fix ที่แนะนำ
### 2. Button & CTA Audit
**ปัญหาที่ต้องตรวจ:**
- ปุ่มที่ใช้ `bg-white/10` หรือ `bg-transparent` บนพื้น Light → ตัวหนังสือหาย
- ปุ่ม "DISCOVER" / "Learn More" สีอ่อนบนพื้นอ่อน
**Fix Pattern:**
```tsx
// ❌ Bad — กลมกลืน
<button className="bg-white/20 text-spa-gold border border-spa-gold/30">
DISCOVER
</button>
// ✅ Good — ชัดเจน
<button className="bg-spa-forest text-white border-2 border-spa-forest hover:bg-spa-stone">
DISCOVER
</button>
// หรือ Outlined ที่ยังชัด
<button className="bg-transparent text-spa-forest border-2 border-spa-forest hover:bg-spa-forest hover:text-white">
DISCOVER
</button>
```
### 3. Navigation & Menu Audit
**ปัญหาที่ต้องตรวจ:**
- Menu items ใช้ `text-slate-300` หรือ `text-gray-400` บนพื้นขาว
- Mobile Drawer menu links ที่อ่านยาก
**Fix Pattern:**
```tsx
// ❌ Bad
<Link className="text-slate-300">ติดต่อเรา</Link>
<Link className="text-gray-400">ค้าที่พบบ่อย</Link>
// ✅ Good
<Link className="text-spa-stone hover:text-spa-forest">ติดต่อเรา</Link>
<Link className="text-spa-stone hover:text-spa-forest">ค้าที่พบบ่อย</Link>
```
### 4. Input / Textbox Audit
**ปัญหาที่ต้องตรวจ:**
- Input ที่มี `border-white/20` หรือ `bg-white/10` บนพื้นขาว → มองไม่เห็นกรอบ
- Placeholder สีอ่อนเกินไป
**Fix Pattern:**
```tsx
// ❌ Bad — กรอบกลมกลืน
<input className="bg-white/10 border border-white/20 placeholder-white/30" />
// ✅ Good — กรอบชัดเจน
<input className="bg-white border border-spa-stone/30 placeholder-spa-stone/50
focus:border-spa-forest focus:ring-1 focus:ring-spa-forest
text-spa-stone" />
```
### 5. Price & Label Audit
**ปัญหาที่ต้องตรวจ:**
- ราคา `฿69` ที่แสดงด้วยสีอ่อนบน `spa-light`
**Fix Pattern:**
```tsx
// ❌ Bad
<span className="text-amber-200/80">฿69</span>
// ✅ Good
<span className="text-spa-forest font-semibold">฿69</span>
```
---
## 🔍 Audit Workflow (ขั้นตอนการตรวจสอบ)
### Step 1: Scan ไฟล์ Component ทั้งหมด
```bash
# ค้นหา Tailwind class สีที่น่าสงสัย
grep -rn "text-slate\|text-gray\|text-white\|bg-white/\|text-amber\|text-cyan\|opacity-" \
apps/spa/src/
```
### Step 2: จัดกลุ่มปัญหา
แบ่งเป็น 3 ระดับความเร่งด่วน:
- 🔴 **Critical** — อ่านไม่ออกเลย (ratio < 2:1)
- 🟡 **Warning** — อ่านยาก (ratio 2:1 – 4.5:1)
- 🟢 **Pass** — ผ่าน WCAG AA (ratio ≥ 4.5:1)
### Step 3: สร้าง Audit Report
สร้างรายงานในรูปแบบ Markdown ที่ระบุ:
- ชื่อไฟล์ + Line Number
- Class ที่ปัจจุบัน (ปัญหา)
- Class ที่แนะนำ (Fix)
- Contrast Ratio ก่อน/หลัง
### Step 4: แก้ไขโค้ด
แก้ไขตามลำดับ Critical → Warning โดยใช้ `multi_replace_file_content` tool
### Step 5: ตรวจสอบซ้ำ
หลังแก้ไข ให้ทำ Re-scan เพื่อยืนยันว่าแก้ครบแล้ว
---
## 🚫 Constraints & Rules (กฎเหล็กในการทำงาน)
> [!IMPORTANT]
> **RULE #1: ห้ามเปลี่ยน Color Palette ใน tailwind.config.js**
> หน้าที่ของคุณคือ "ใช้สีที่มีอยู่ให้ถูกต้อง" ไม่ใช่สร้างสีใหม่ หากเห็นว่า Palette ต้องการสีเพิ่ม ให้แจ้ง Orchestrator
> [!WARNING]
> **RULE #2: ห้ามแตะต้อง Business Logic**
> แก้ได้เฉพาะ Tailwind CSS classes ที่เกี่ยวกับสีและการแสดงผลเท่านั้น
> [!CAUTION]
> **RULE #3: ต้อง Cross-check กับ Dark Mode / Overlay Components**
> บาง Component อาจมีพื้นหลังซ้อนกัน (เช่น Image + Gradient Overlay) ต้องตรวจสอบให้ครบทุก Layer
- **RULE #4: รายงานต้องครบก่อนแก้**
สร้าง Audit Report ให้ครบก่อนเสมอ ไม่แก้ทีละจุดแบบไม่มีแผน
- **RULE #5: Hover/Focus State ต้องตรวจด้วย**
ตรวจทั้ง Default, Hover, Focus, Disabled State ไม่ใช่แค่ Default เท่านั้น
---
## 📋 Quick Reference: Fix Cheatsheet สำหรับ HomAroma
| สถานการณ์ | ❌ ห้ามใช้ | ✅ ควรใช้ |
|---|---|---|
| ตัวหนังสือบนพื้น Light/White | `text-slate-300`, `text-gray-400`, `text-white/60` | `text-spa-stone`, `text-spa-forest` |
| ตัวหนังสือ Accent/เน้น | `text-spa-gold` บนพื้นขาว | `text-spa-gold` บน `spa-forest` เท่านั้น |
| ปุ่มหลัก (Primary) | `bg-white/10 text-white` | `bg-spa-forest text-white` |
| ปุ่มรอง (Secondary/Ghost) | `border-white/20 text-white/50` | `border-spa-forest text-spa-forest` |
| Input box บนพื้นขาว | `border-white/20 bg-white/10` | `border-spa-stone/40 bg-white` |
| Placeholder text | `placeholder-white/30` | `placeholder-spa-stone/50` |
| ราคาสินค้า | `text-amber-200` | `text-spa-forest font-semibold` |
| หัวข้อ Section | `text-white` บนพื้น Light | `text-spa-forest` |
| Icon บน Light bg | `text-white`, `text-gray-300` | `text-spa-stone`, `text-spa-forest` |
+85
View File
@@ -0,0 +1,85 @@
---
name: management-talk
description: Rewrite engineer-to-engineer content for engineering-org leadership (VPs, directors, PMs) and shape it specifically for Taskboard MCP operations (submit_task_result, add_comment, create_sub_task). Trigger when the user asks to write/rewrite an update, report, or summary for management, or asks to make technical text less jargony specifically for taskboard updates.
---
# Management Talk for Taskboard MCP
Same audience and translation rules as a traditional written status report, but **shaped specifically for Taskboard MCP operations** — `submit_task_result`, `add_comment`, or `create_sub_task`. The audience reads project names, task numbers, and high-level impacts, but not deep code logic.
Use this any time engineering content needs to be summarized and logged into the Taskboard for management, product teams, or cross-functional tracking.
## When to invoke
- "write something for management / PM to put in the taskboard"
- "rewrite this technical update for the taskboard comment"
- "make this non-technical" / "less techy" / "less jargony" for a task update
- "executive summary" / "status update" to submit the task result
- "create a sub-task description based on this technical bug"
## Audience — what "engineering-org leadership" means
Engineering-savvy non-engineers: VPs, directors, PMs, release managers. They read product/framework names and cross-reference Task IDs. They do not read code.
They want: *what's the state, what does it mean for customers, who owns it, what's next.* They do not want: how the bug works at the function level.
This is **not** for marketing, finance, or customer-facing audiences — those need a different rewrite. Flag and confirm before producing one.
## Tone
**Keep.** Product names, framework names, team-owned component names, Task IDs, PR numbers, customer/workload identifiers. These are the bridge between engineering and leadership tracking.
**Strip.** Function names, file paths, struct fields, commit SHAs, code expressions, env var names, line numbers, internal data-structure jargon. None of this is actionable to the audience.
**Translate.** Mechanism into one or two sentences of plain-English cause-and-effect. Not *"the kernel reads from `scratchBuf == NULL`"* but *"the GPUs end up reading from an uninitialized buffer and wait forever for a signal that never arrives."*
**Bias toward** active voice, concrete subjects, short paragraphs. *"We found the bug. Alex wrote the fix. PR is up for review."* beats *"The root cause has been identified and a fix has been authored and submitted for review."*
## Channel shapes (Taskboard MCP Tools)
Same content, different shell. Pick the shape that matches the target MCP tool.
### 1. Task Result (`submit_task_result`)
Full structured block used when closing a task. Easy to scan.
Building blocks (use as many as fit):
- **Status / TL;DR.** One clear line. *"Fixed and merged."* / *"Workaround applied, full fix pending next sprint."*
- **Impact.** Who's affected, how badly, what they see.
- **What broke / What was done.** Short paragraph. Plain-English mechanism, no code identifiers.
- **Owner / Links.** PR number or reference links.
- **Next steps / Risks.** Real risks only or what follows after closing this.
### 2. Task Comment (`add_comment`)
Used for continuous updates, flagging blockers, or daily progress. No walls of text.
- One **TL;DR** as the first line.
- 2–3 short bullets underneath: current state, blockers (if any), next step.
- Do not use heavy bolded section labels like the Task Result. Keep it brief and scannable.
### 3. Sub-Task Description (`create_sub_task`)
Used when breaking down a highly technical issue into actionable work for others.
- **Goal:** One sentence explaining what needs to be done.
- **Context:** Why we are doing this (plain English).
- **Technical Hints (Optional):** Keep this brief, pointing to where the engineer should look (this is the only place some technical jargon is acceptable if the assignee is an engineer, but keep it high-level).
- **Acceptance Criteria:** How to know it's done.
## Source material
The input is one of:
1. **A Task ID** → fetch via `call_mcp_tool` `get_task_detail` to read current status and comments.
2. **Pasted technical text** → use directly.
3. **The current conversation** → if you just produced engineering content, reuse what's in context.
## Output flow
1. **Confirm the target tool** if it's not stated (Result, Comment, or Sub-task).
2. **Produce the draft** formatted for the Taskboard as a chat message.
3. **Ask before executing:** Show the exact drafted text, wait for explicit *"post it"* / *"go ahead"*, then execute the corresponding MCP tool (`submit_task_result`, `add_comment`, etc.).
4. **Never post automatically** without the user seeing the drafted management-talk first.
## Rules
- **Never invent facts** to make the rewrite cleaner.
- **Never strip a Task ID, PR number, or product name** during de-jargoning.
- **Get sign-off before executing the MCP tool.**
+142
View File
@@ -0,0 +1,142 @@
---
name: product-manager
description: Product Manager Agent วิเคราะห์ความสมบูรณ์ของ Product วางแผน Feature Roadmap ตรวจจับ Gap ที่ขาดหายไป และจัดลำดับความสำคัญด้วย Business Impact × Effort Matrix สำหรับโปรเจกต์ HomAroma E-commerce
---
# Skill: Product Manager Agent
## Role
คุณคือ **Product Manager Agent** ของโปรเจกต์ HomAroma
ทำหน้าที่วิเคราะห์ความสมบูรณ์ของระบบ E-commerce เทียบกับ Industry Standard เพื่อค้นหา Feature Gap และวางแผน Roadmap ที่สร้าง Business Value สูงสุดในเวลาน้อยที่สุด
## Context: โปรเจกต์ HomAroma
- **Product Type:** E-commerce สำหรับผลิตภัณฑ์เครื่องหอม / น้ำมันหอมระเหย
- **Primary Channel:** `apps/spa` (LINE LIFF Mobile App) — ลูกค้าซื้อผ่าน LINE OA
- **Secondary Channel:** `apps/website` (Next.js SSR) — ดึงลูกค้าใหม่ผ่าน Google
- **Backend:** ElysiaJS + Prisma + PostgreSQL
- **Business Model:** Direct-to-Consumer (DTC), รับ Payment ผ่านโอนเงิน + แนบสลิป
---
## 🛠️ Capabilities
### 1. Feature Gap Analysis
เปรียบเทียบกับ E-commerce Checklist มาตรฐาน แล้วระบุว่าสิ่งใด:
- ✅ มีแล้ว (Implemented)
- 🚧 มีบางส่วน (Partial)
- ❌ ขาดหายไป (Missing)
### 2. Priority Matrix (Impact × Effort)
จัดลำดับ Feature ด้วยกรอบ:
```
High Impact + Low Effort = 🔴 DO FIRST (Quick Win)
High Impact + High Effort = 🟡 PLAN CAREFULLY (Big Bets)
Low Impact + Low Effort = 🟢 FILL IN GAPS (Nice to Have)
Low Impact + High Effort = ⚫ AVOID (Time Sink)
```
### 3. Roadmap Planning
วางแผนเป็น Phase ที่ชัดเจน:
- **Phase 1 (MVP Fix):** แก้ไขสิ่งที่มีแต่พังหรืออ่านไม่ออก
- **Phase 2 (Core Features):** เพิ่ม Feature ที่ขาดแต่จำเป็น
- **Phase 3 (Growth):** Feature ที่เพิ่ม Conversion และ Retention
### 4. Taskboard Integration
สร้าง Task ใน Taskboard MCP โดยอัตโนมัติ:
- Main Task = Feature หรือ Epic
- Sub Task = งานย่อยที่ต้องทำ
- ใส่ Priority, Effort, Business Impact ลงใน Description
---
## 📋 E-commerce Feature Checklist (HomAroma Context)
### 🛍️ Customer-Facing (apps/spa)
#### Browse & Discovery
- [ ] หน้าแรก / Hero Banner
- [ ] หมวดหมู่สินค้า (Category Grid)
- [ ] หน้ารายการสินค้า (Shop/All Products)
- [ ] หน้าสินค้าในหมวดหมู่ (Category Page)
- [ ] หน้ารายละเอียดสินค้า (Product Detail)
- [ ] ค้นหาสินค้า (Search)
- [ ] กรองและเรียงสินค้า (Filter & Sort)
- [ ] สินค้าที่เกี่ยวข้อง (Related Products)
- [ ] สินค้าแนะนำล่าสุด (Recently Viewed)
#### Cart & Checkout
- [ ] ตะกร้าสินค้า (Cart Drawer)
- [ ] แก้ไขจำนวน / ลบสินค้าในตะกร้า
- [ ] ใส่โค้ดคูปอง (Coupon Code)
- [ ] กรอกข้อมูลจัดส่ง (Checkout Form)
- [ ] สรุปออเดอร์ก่อนจ่ายเงิน (Order Summary)
- [ ] อัปโหลดสลิปโอนเงิน (Payment Slip Upload)
- [ ] บันทึกที่อยู่จัดส่ง (Address Book)
- [ ] เลือก Variant สินค้า (Size/Type Selector)
#### Post-Purchase
- [ ] หน้ายืนยันออเดอร์ (Order Success Page)
- [ ] ติดตามสถานะออเดอร์ (Order Tracking)
- [ ] ประวัติการสั่งซื้อ (Order History)
- [ ] แก้ไข / ยกเลิกออเดอร์ (Order Edit/Cancel)
- [ ] แจ้งเตือนสถานะ (LINE Notification)
#### Customer Account
- [ ] Login ด้วย LINE (LIFF Login)
- [ ] โปรไฟล์ลูกค้า (Customer Profile)
- [ ] Wishlist / รายการโปรด
- [ ] สะสมแต้ม / Loyalty Program
- [ ] รีวิวสินค้า (Product Review)
#### Customer Service
- [ ] หน้า FAQ
- [ ] หน้าติดต่อเรา (Contact)
- [ ] แชทสด / LINE OA Integration
- [ ] ระบบแจ้งปัญหา (Support Ticket)
#### Content & Trust
- [ ] หน้าเกี่ยวกับเรา (About)
- [ ] บทความ / Scent Guide (Blog)
- [ ] นโยบายความเป็นส่วนตัว (Privacy Policy)
- [ ] นโยบายการคืนสินค้า (Return Policy)
- [ ] คูปองและโปรโมชั่น (Promotions Page)
### ⚙️ Admin-Facing (apps/admin)
- [ ] จัดการสินค้า (Product CRUD)
- [ ] จัดการออเดอร์ (Order Management)
- [ ] ค้นหาออเดอร์ (Order Search/Filter)
- [ ] แก้ไขสถานะออเดอร์ (Order Status Update)
- [ ] จัดการคูปอง (Coupon Management)
- [ ] จัดการลูกค้า (Customer Management)
- [ ] Dashboard ยอดขาย (Sales Dashboard)
- [ ] รายงานสินค้าขายดี (Product Analytics)
- [ ] จัดการบทความ (Article CMS)
- [ ] ตั้งค่าร้านค้า (Store Settings)
- [ ] จัดการ Hero Banner (Hero CMS)
---
## 🚫 Constraints & Rules
> [!IMPORTANT]
> **RULE #1: วิเคราะห์จากโค้ดจริงเสมอ ไม่ใช่แค่เดา**
> ก่อนตัดสินว่า Feature ไหน "ขาด" ต้องตรวจดูโค้ดใน `apps/spa/src/pages/` และ `apps/backend/src/routes/` ก่อนเสมอ
> [!WARNING]
> **RULE #2: ระบุ Business Impact ทุกครั้ง**
> Feature แต่ละอันต้องบอกได้ว่าช่วยเรื่องใด: Revenue / Retention / Cost Reduction / Trust
- **RULE #3: สร้าง Task ใน Taskboard เสมอหลังวิเคราะห์เสร็จ**
ผลการวิเคราะห์ต้องถูกแปลงเป็น Actionable Tasks ใน Taskboard MCP ไม่ใช่แค่รายงาน
- **RULE #4: จัดกลุ่ม Task ตาม Phase เสมอ**
Phase 1 (Fix), Phase 2 (Core), Phase 3 (Growth) — ห้ามเอาทุกอย่างมาปนกัน
---
## 🔄 Workflow
1. **Scan:** ตรวจสอบ `apps/spa/src/pages/`, `apps/backend/src/routes/`, `apps/admin/src/`
2. **Audit:** เปรียบเทียบกับ Feature Checklist ด้านบน
3. **Prioritize:** จัดลำดับด้วย Impact × Effort Matrix
4. **Report:** สร้าง Audit Report พร้อม Gap List
5. **Create Tasks:** สร้าง Main Tasks + Sub Tasks ใน Taskboard MCP
+137
View File
@@ -0,0 +1,137 @@
---
name: qa-product-auditor
description: QA Product Auditor Agent ตรวจสอบว่า User Flow ทุกเส้นทางใน apps/spa ทำงานได้จริงหรือไม่ ค้นหา Broken Flows, Missing Error States, Empty States และ UX Issues ก่อน Production Release
---
# Skill: QA Product Auditor Agent
## Role
คุณคือ **QA Product Auditor Agent** ของโปรเจกต์ HomAroma
ทำหน้าที่เดินผ่านทุก User Flow และตรวจสอบว่าแต่ละขั้นตอนทำงานได้ครบถ้วน มี Error Handling ที่ดี และ UX ไม่ติดขัด ก่อนที่จะ Release ให้ลูกค้าจริงใช้งาน
## Context
- **Primary App:** `apps/spa` (LINE LIFF Mobile App)
- **Backend:** `apps/backend` (ElysiaJS API at PORT 3051)
- **Stack:** React + Vite + TailwindCSS
---
## 🛠️ Audit Scope
### Flow ที่ต้องตรวจทุกครั้ง:
#### Flow 1: Browse & Discovery
```
หน้าแรก → เลือกหมวดหมู่ → ดูสินค้า → คลิกสินค้า
```
ตรวจสอบ:
- [ ] หน้าแรกโหลดข้อมูลจาก API ได้
- [ ] Hero Banner แสดงภาพถูกต้อง
- [ ] Category Grid แสดงครบ
- [ ] Product Card แสดงชื่อ, ราคา, รูปภาพ ชัดเจน อ่านออก
- [ ] Loading State ขณะดึงข้อมูล
- [ ] Empty State เมื่อไม่มีสินค้า
#### Flow 2: Product Detail
```
คลิกสินค้า → ดูรายละเอียด → เลือก Variant → เพิ่มลงตะกร้า
```
ตรวจสอบ:
- [ ] ข้อมูลสินค้าโหลดครบ (ชื่อ, ราคา, รายละเอียด, รูปภาพ)
- [ ] Variant Selector (เลือกขนาด/ประเภท) ทำงานได้
- [ ] ราคาเปลี่ยนตาม Variant ที่เลือก
- [ ] ปุ่ม "เพิ่มลงตะกร้า" เพิ่มสินค้าจริง
- [ ] แสดง Feedback เมื่อเพิ่มสำเร็จ
- [ ] Related Products แสดงและ clickable
#### Flow 3: Cart Management
```
เปิดตะกร้า → แก้จำนวน → ลบสินค้า → ปิดตะกร้า
```
ตรวจสอบ:
- [ ] Cart Drawer เปิด/ปิดได้
- [ ] รายการสินค้าในตะกร้าแสดงถูกต้อง
- [ ] เพิ่ม/ลดจำนวนได้
- [ ] ลบสินค้าออกได้
- [ ] ราคารวมคำนวณถูกต้อง
- [ ] Empty Cart State
#### Flow 4: Checkout (Critical Path)
```
กดชำระเงิน → กรอกข้อมูล → ใส่คูปอง → ยืนยันออเดอร์
```
ตรวจสอบ:
- [ ] Form Validation ครบทุก Field
- [ ] คูปองโค้ด validate ได้
- [ ] ส่วนลดคำนวณถูกต้อง
- [ ] ค่าส่งแสดงถูกต้อง
- [ ] ยอดรวมสุดท้ายถูกต้อง
- [ ] กด "สั่งซื้อ" สร้าง Order ใน Database ได้
- [ ] Error Handling เมื่อ API ล้มเหลว
#### Flow 5: Payment
```
ยืนยันออเดอร์ → หน้า Success → อัปโหลดสลิป
```
ตรวจสอบ:
- [ ] หน้า Order Success แสดง Order ID ถูกต้อง
- [ ] แสดงข้อมูลบัญชีธนาคาร (จาก Settings API)
- [ ] Upload สลิปได้ (Image Picker)
- [ ] สลิปถูก Upload ไปยัง Server
- [ ] Order ถูก Update สถานะหลัง Upload สำเร็จ
#### Flow 6: Order Tracking
```
หน้าติดตามพัสดุ → ค้นหา Order → ดูสถานะ
```
ตรวจสอบ:
- [ ] ค้นหาด้วย Order ID / เบอร์โทร
- [ ] แสดงสถานะออเดอร์ที่อ่านเข้าใจง่าย
- [ ] Timeline สถานะ (Pending → Processing → Shipped → Delivered)
#### Flow 7: Error & Edge Cases
ตรวจสอบ:
- [ ] API ไม่ตอบสนอง → แสดง Error Message ที่เข้าใจได้
- [ ] Network Offline → แสดงข้อความ
- [ ] สินค้าหมด → แสดง "Out of Stock" และปิดปุ่มซื้อ
- [ ] รูปภาพโหลดไม่ได้ → แสดง Fallback Image
- [ ] ราคา null/undefined → แสดง "-" ไม่ใช่ crash
---
## 📊 Severity Levels
| Level | ความหมาย | ตัวอย่าง |
|---|---|---|
| 🔴 **Blocker** | ทำให้ใช้งานไม่ได้เลย | กด "สั่งซื้อ" แล้ว Error |
| 🟠 **Critical** | Flow หลักเสียหาย | ราคาคำนวณผิด |
| 🟡 **Major** | ประสบการณ์แย่มาก | Loading ไม่มี Indicator |
| 🟢 **Minor** | ปัญหาเล็กน้อย | ข้อความ Placeholder ยังเป็นภาษาอังกฤษ |
---
## 🔄 Audit Workflow
1. **Setup:** ตรวจสอบว่า `apps/backend` รันอยู่ที่ port 3051
2. **Code Scan:** อ่านโค้ดใน `apps/spa/src/pages/` ทุกไฟล์
3. **API Check:** ตรวจสอบว่า API Endpoint ที่ใช้มีอยู่ใน `apps/backend/src/routes/`
4. **Flow Trace:** Trace โค้ดของแต่ละ Flow ว่า Success/Error Path ครบไหม
5. **Report:** สร้าง Audit Report จัดกลุ่มตาม Severity
6. **Tasks:** สร้าง Bug/Fix Tasks ใน Taskboard MCP
---
## 🚫 Constraints & Rules
> [!IMPORTANT]
> **RULE #1: ตรวจจาก Source Code ไม่ใช่การ Assume**
> ต้องอ่านไฟล์จริงก่อนตัดสินว่า Feature ใด "พัง" หรือ "ขาด"
> [!WARNING]
> **RULE #2: แยก Bug จาก Feature Gap ให้ชัด**
> - Bug = มีโค้ดแล้วแต่ทำงานผิด
> - Feature Gap = ยังไม่มีโค้ดเลย
> สองอย่างนี้ต้องรายงานแยกกัน
- **RULE #3: ทุก Bug ต้องระบุ File + Line Number**
ห้ามรายงานแบบคลุมเครือ ต้องชี้ได้ว่าปัญหาอยู่ตรงไหน
+8 -8
View File
@@ -10,7 +10,7 @@
* **เทคโนโลยีที่ใช้:** Bun, Elysia.js, Prisma 7, PostgreSQL
* **การทำงาน:** ทำหน้าที่เป็น RESTful API Server ที่จัดการข้อมูลทั้งหมดของระบบ ตั้งแต่การตรวจสอบสิทธิ์ (Authentication), การจัดการสินค้า (Products), หมวดหมู่ (Categories), คำสั่งซื้อ (Orders) ไปจนถึงการตั้งค่าต่างๆ ของร้านค้า
* **เป้าหมาย:** เพื่อให้เป็นจุดเชื่อมต่อข้อมูลเดียว (Single Source of Truth) ที่มีความรวดเร็วสูงด้วย Bun และมีความปลอดภัยในการเข้าถึงข้อมูล
* **การเชื่อมโยง:** เชื่อมต่อกับฐานข้อมูล PostgreSQL และให้บริการ API แก่ `admin`, `ssr`, และ `website`
* **การเชื่อมโยง:** เชื่อมต่อกับฐานข้อมูล PostgreSQL และให้บริการ API แก่ `admin`, `website`, และ `spa`
## 2. Admin Portal (`apps/admin`)
**ระบบจัดการหลังบ้าน (Back-office)**
@@ -20,7 +20,7 @@
* **เป้าหมาย:** เพื่อให้ผู้ดูแลระบบสามารถบริหารจัดการร้านค้าได้อย่างมีประสิทธิภาพผ่าน Interface ที่ใช้งานง่าย
* **การเชื่อมโยง:** เชื่อมต่อกับ `backend` ผ่าน API เพื่อดึงและอัปเดตข้อมูลต่างๆ
## 3. Main Website (`apps/ssr`)
## 3. Main Website (`apps/website`)
**เว็บไซต์หลัก (Public Interface)**
* **เทคโนโลยีที่ใช้:** Next.js (React), Tailwind CSS
@@ -28,7 +28,7 @@
* **เป้าหมาย:** เพื่อสร้างการรับรู้แบรนด์ (Brand Awareness) และช่วยให้ลูกค้าค้นหาพบผ่าน Search Engine ได้ง่าย
* **การเชื่อมโยง:** เชื่อมต่อกับ `backend` เพื่อดึงข้อมูลสินค้าและเนื้อหามาแสดงผลบนหน้าเว็บ
## 4. LIFF Mobile App (`apps/website`)
## 4. LIFF Mobile App (`apps/spa`)
**หน้ากากมือถือสำหรับลูกค้า (Customer Interface)**
* **เทคโนโลยีที่ใช้:** React, Vite, LINE Front-end Framework (LIFF), Tailwind CSS
@@ -91,16 +91,16 @@ git push server master
* Sync โค้ดไปยัง `/home/ti/projects/homaroma-api`
* รัน `prisma generate` และ `prisma migrate deploy` เพื่ออัปเดตฐานข้อมูล
* Restart บริการด้วย **PM2** (ชื่อ process: `homaroma-backend`)
4. **SSR Deployment**:
* Build Next.js app และ Sync ไปยัง `/home/ti/projects/homaroma-ssr`
* Restart บริการด้วย **PM2** (ชื่อ process: `homaroma-ssr`) บนพอร์ต `3001`
4. **Website (SSR) Deployment**:
* Build Next.js app และ Sync ไปยัง `/home/ti/projects/homaroma-website`
* Restart บริการด้วย **PM2** (ชื่อ process: `homaroma-website`) บนพอร์ต `3002`
5. **Cleanup**: ลบไฟล์ชั่วคราวที่ใช้ในการ Build ออก
### สรุป URL การเข้าใช้งาน (Production)
* **Website (SPA):** [https://homaroma.shop](https://homaroma.shop)
* **Website (SSR Next.js):** [https://homaroma.shop](https://homaroma.shop)
* **SPA (LIFF Mobile App):** [https://spa.homaroma.shop](https://spa.homaroma.shop)
* **Admin Panel:** [https://admin.homaroma.shop](https://admin.homaroma.shop)
* **API Backend:** [https://api.homaroma.shop](https://api.homaroma.shop)
* **SSR Next.js:** [https://ssr.homaroma.shop](https://ssr.homaroma.shop)
---
+37 -37
View File
@@ -16,81 +16,81 @@ export default function CartDrawer() {
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm animate-fade-in"
className="absolute inset-0 bg-pop-black/60 backdrop-blur-sm animate-fade-in"
onClick={() => toggleCart(false)}
/>
{/* Drawer */}
<div className="relative w-full max-w-md h-full bg-night-900 shadow-2xl flex flex-col border-l border-white/10 animate-slide-left">
<div className="relative w-full max-w-md h-full bg-white shadow-2xl flex flex-col animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-night-950/50">
<h2 className="text-xl font-bold flex items-center gap-2">
<ShoppingBag className="w-5 h-5 text-cyan-glow" />
<div className="p-6 border-b border-spa-stone/10 flex items-center justify-between bg-spa-light">
<h2 className="text-xl font-bold flex items-center gap-2 text-pop-black">
<ShoppingBag className="w-5 h-5 text-spa-forest" />
{t('cart.title')}
<span className="text-sm font-normal text-slate-400 bg-white/5 px-2 py-0.5 rounded-full ml-2">
<span className="text-sm font-normal text-spa-stone bg-spa-sage px-2 py-0.5 rounded-full ml-2">
{t('cart.items', { count: items.length })}
</span>
</h2>
<button
onClick={() => toggleCart(false)}
className="p-2 hover:bg-white/5 rounded-full transition-colors"
className="p-2 hover:bg-spa-sage rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400 hover:text-white" />
<X className="w-5 h-5 text-spa-stone hover:text-pop-black" />
</button>
</div>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500 space-y-4">
<div className="h-full flex flex-col items-center justify-center text-spa-stone space-y-4">
<ShoppingBag className="w-16 h-16 opacity-20" />
<p>{t('cart.empty')}</p>
<button
onClick={() => toggleCart(false)}
className="text-cyan-glow hover:underline tracking-widest uppercase text-xs font-bold"
className="text-spa-forest hover:underline tracking-widest uppercase text-xs font-bold"
>
{t('cart.continueShopping')}
</button>
</div>
) : (
items.map((item) => (
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white/5 border border-white/5 group relative">
<div className="w-18 h-22 rounded-lg bg-night-950 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white border border-spa-stone/10 shadow-sm group relative">
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="w-full h-full object-cover opacity-80"
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0">
<h3 className="font-bold text-sm leading-tight pr-6 truncate">{item.productName}</h3>
<h3 className="font-bold text-sm text-spa-forest leading-tight pr-6 truncate">{item.productName}</h3>
{item.variantSize && (
<p className="text-xs text-slate-500">{item.variantSize}</p>
<p className="text-xs text-spa-stone">{item.variantSize}</p>
)}
{/* Price row */}
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-xs text-slate-500">{formatPrice(item.price)}</span>
<span className="text-xs text-slate-600">×</span>
<span className="text-xs text-slate-500">{item.quantity}</span>
<span className="text-xs text-slate-600">=</span>
<span className="text-sm font-bold text-cyan-glow">{formatPrice(item.price * item.quantity)}</span>
<span className="text-xs text-spa-stone/80">{formatPrice(item.price)}</span>
<span className="text-xs text-spa-stone/60">×</span>
<span className="text-xs text-spa-stone/80">{item.quantity}</span>
<span className="text-xs text-spa-stone/60">=</span>
<span className="text-sm font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</span>
</div>
{/* Quantity controls */}
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 bg-night-950/70 rounded-lg border border-white/5">
<div className="flex items-center gap-2 bg-spa-sage/50 rounded-lg border border-spa-stone/5">
<button
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
className="p-1.5 hover:text-cyan-glow transition-colors"
className="p-1.5 text-spa-stone hover:text-spa-forest transition-colors"
>
<Minus className="w-3 h-3" />
</button>
<span className="w-5 text-center text-xs font-bold">{item.quantity}</span>
<span className="w-5 text-center text-xs font-bold text-pop-black">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
className="p-1.5 hover:text-cyan-glow transition-colors"
className="p-1.5 text-spa-stone hover:text-spa-forest transition-colors"
>
<Plus className="w-3 h-3" />
</button>
@@ -99,7 +99,7 @@ export default function CartDrawer() {
<button
onClick={() => removeFromCart(item.cartItemId)}
className="absolute top-0 right-0 p-1.5 text-slate-600 hover:text-rose-500 transition-colors"
className="absolute top-0 right-0 p-1.5 text-spa-stone/50 hover:text-rose-500 transition-colors"
title="Remove item"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -159,42 +159,42 @@ function CartDrawerFooter({ subtotal, onClose }: { subtotal: number; onClose: ()
return (
<div className="p-5 border-t border-white/10 bg-night-950/60 space-y-4">
<div className="p-5 border-t border-spa-stone/10 bg-spa-light space-y-4">
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between text-slate-400">
<div className="space-y-2 text-sm text-pop-black">
<div className="flex justify-between text-spa-stone">
<span>ราคาสินค้า</span>
<span>{formatPrice(subtotal)}</span>
</div>
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
<div className="flex justify-between text-cyan-glow">
<div className="flex justify-between text-spa-forest font-medium">
<span className="flex items-center gap-1.5">
ส่วนลดคูปอง
<span className="text-[10px] font-bold bg-cyan-glow/15 px-1.5 py-0.5 rounded tracking-widest">
<span className="text-[10px] font-bold bg-spa-sage px-1.5 py-0.5 rounded tracking-widest text-spa-forest">
{savedCoupon}
</span>
<button onClick={removeCoupon} className="text-slate-500 hover:text-rose-400 transition-colors" title="ยกเลิก">
<button onClick={removeCoupon} className="text-spa-stone/50 hover:text-rose-500 transition-colors" title="ยกเลิก">
<X className="w-3 h-3" />
</button>
</span>
<span>{isFreeShipping ? 'ส่งฟรี' : `- ${formatPrice(discountAmount)}`}</span>
<span className="text-rose-500">{isFreeShipping ? 'ส่งฟรี' : `- ${formatPrice(discountAmount)}`}</span>
</div>
)}
{savedCoupon && !couponValid && (
<div className="flex justify-between text-amber-400 text-xs">
<div className="flex justify-between text-amber-600 text-xs font-medium">
<span className="flex items-center gap-1.5">
<Tag className="w-3 h-3" />
คูปอง <span className="font-bold tracking-widest">{savedCoupon}</span> (ยังไม่ถึงยอดขั้นต่ำ)
<button onClick={removeCoupon} className="text-slate-500 hover:text-rose-400 transition-colors">
<button onClick={removeCoupon} className="text-spa-stone/50 hover:text-rose-500 transition-colors">
<X className="w-3 h-3" />
</button>
</span>
</div>
)}
<div className="flex justify-between font-bold text-base pt-2 border-t border-white/10">
<div className="flex justify-between font-bold text-base pt-2 border-t border-spa-stone/10 text-pop-black">
<span>รวมสุทธิ</span>
<span className="text-cyan-glow">{formatPrice(Math.max(0, subtotal - discountAmount))}</span>
<span className="text-rose-500">{formatPrice(Math.max(0, subtotal - discountAmount))}</span>
</div>
</div>
@@ -202,7 +202,7 @@ function CartDrawerFooter({ subtotal, onClose }: { subtotal: number; onClose: ()
<Link
to="/checkout"
onClick={onClose}
className="flex items-center justify-center gap-2 w-full py-4 bg-white text-night-900 font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 group"
className="flex items-center justify-center gap-2 w-full py-4 bg-spa-forest text-white font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-spa-stone hover:shadow-lg transition-all duration-300 group"
>
<ShoppingBag className="w-4 h-4" />
ดำเนินการชำระเงิน
+40
View File
@@ -0,0 +1,40 @@
import { PackageOpen } from 'lucide-react'
import { Link } from 'react-router-dom'
interface EmptyStateProps {
title?: string
message?: string
actionText?: string
actionLink?: string
actionClick?: () => void
}
export default function EmptyState({
title = 'Nothing found',
message = 'We could not find any items here.',
actionText = 'Browse Shop',
actionLink,
actionClick
}: EmptyStateProps) {
const ButtonContent = () => (
<span className="inline-block mt-6 px-8 py-3 bg-spa-forest text-white rounded-xl text-sm font-bold uppercase tracking-widest hover:bg-spa-stone hover:shadow-lg transition-all cursor-pointer">
{actionText}
</span>
)
return (
<div className="flex flex-col items-center justify-center space-y-4 text-center p-12 bg-white border border-spa-stone/10 rounded-2xl shadow-sm max-w-lg mx-auto my-8">
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mb-2">
<PackageOpen className="w-8 h-8 text-spa-forest" />
</div>
<h3 className="text-xl font-bold text-pop-black">{title}</h3>
<p className="text-spa-stone">{message}</p>
{actionClick ? (
<div onClick={actionClick}><ButtonContent /></div>
) : actionLink ? (
<Link to={actionLink}><ButtonContent /></Link>
) : null}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
interface ErrorStateProps {
message?: string
fullScreen?: boolean
showHomeButton?: boolean
}
export default function ErrorState({
message = 'Something went wrong',
fullScreen = false,
showHomeButton = true
}: ErrorStateProps) {
const content = (
<div className="flex flex-col items-center justify-center space-y-4 text-center p-8 bg-white border border-rose-500/20 rounded-2xl shadow-sm max-w-md mx-auto">
<div className="w-12 h-12 bg-rose-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-rose-500" />
</div>
<p className="text-pop-black font-semibold">{message}</p>
{showHomeButton && (
<Link
to="/"
className="mt-4 px-6 py-2 bg-spa-forest text-white rounded-lg text-sm font-bold uppercase tracking-widest hover:bg-spa-stone transition-colors"
>
Return Home
</Link>
)}
</div>
)
if (fullScreen) {
return (
<div className="min-h-[60vh] flex items-center justify-center bg-pop-bg px-4">
{content}
</div>
)
}
return content
}
+25
View File
@@ -0,0 +1,25 @@
import { Loader2 } from 'lucide-react'
interface LoadingStateProps {
message?: string
fullScreen?: boolean
}
export default function LoadingState({ message = 'Loading...', fullScreen = false }: LoadingStateProps) {
const content = (
<div className="flex flex-col items-center justify-center space-y-4 text-spa-stone p-8">
<Loader2 className="w-8 h-8 animate-spin text-spa-forest" />
<p className="text-sm font-semibold tracking-widest uppercase">{message}</p>
</div>
)
if (fullScreen) {
return (
<div className="min-h-[60vh] flex items-center justify-center bg-pop-bg">
{content}
</div>
)
}
return content
}
+61 -61
View File
@@ -1,7 +1,7 @@
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import type { Category } from '../types'
import { ShoppingBag, MapPin } from 'lucide-react'
import { ShoppingBag, MapPin, Menu as MenuIcon, X } from 'lucide-react'
import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { logoutLiff, loginLiff } from '../lib/liff'
@@ -63,19 +63,19 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
isActive ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
isActive ? 'text-spa-forest font-semibold' : 'text-spa-stone hover:text-spa-forest transition-colors'
return (
<>
{/* ── NAVBAR BAR ── */}
<nav
className={`fixed top-0 w-full z-50 transition-all duration-300 px-4 md:px-6 py-3.5 md:py-4 flex justify-between items-center ${
scrolled ? 'bg-night-900/95 backdrop-blur-xl border-b border-white/10 shadow-xl' : 'glass backdrop-blur-xl border-b border-white/5'
className={`fixed top-0 w-full z-50 transition-all duration-300 px-4 md:px-6 py-3.5 md:py-4 flex justify-between items-center bg-white/80 backdrop-blur-xl border-b border-black/5 ${
scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none'
}`}
>
{/* Logo */}
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0">
HomAroma <span className="hidden md:inline text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60 ml-1">Essential</span>
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
HomAroma <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
</Link>
{/* ── DESKTOP NAV ── */}
@@ -87,19 +87,19 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Collections dropdown */}
{categories.length > 0 && (
<div className="relative group">
<button className="text-slate-300 hover:text-white transition-colors flex items-center gap-1 uppercase text-sm font-medium tracking-wide">
<button className="text-spa-stone hover:text-spa-forest transition-colors flex items-center gap-1 uppercase text-sm font-medium tracking-wide">
{t('nav.collections')}
<svg className="w-3 h-3 opacity-50 transition-transform duration-300 group-hover:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 w-52 bg-night-900/95 backdrop-blur-xl border border-white/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 w-52 bg-white/95 backdrop-blur-xl border border-black/5 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
<div className="p-2 space-y-0.5">
{categories.map((cat) => (
<Link
key={cat.id}
to={`/category/${cat.id}`}
className="flex items-center justify-between px-4 py-2.5 text-xs uppercase tracking-widest rounded-lg hover:bg-white/10 hover:text-cyan-glow transition-colors"
className="flex items-center justify-between px-4 py-2.5 text-xs uppercase tracking-widest text-spa-stone rounded-lg hover:bg-spa-sage hover:text-spa-forest transition-colors"
>
{cat.name}
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -120,11 +120,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<div className="hidden md:flex items-center gap-3">
<Link
to="/track"
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
className="relative p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
aria-label="Track Orders"
title="Track Orders"
>
<MapPin className="w-4 h-4 group-hover:text-cyan-glow transition-colors" />
<MapPin className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
{localOrdersCount > 0 && (
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full animate-pulse" />
)}
@@ -132,36 +132,36 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<button
onClick={() => setSearchOpen(true)}
className="glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
className="p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
aria-label="Search"
>
<svg className="w-4 h-4 group-hover:text-cyan-glow transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4 group-hover:text-spa-forest transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button
onClick={() => toggleCart(true)}
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
className="relative p-2.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all group"
aria-label="Cart"
>
<ShoppingBag className="w-4 h-4 group-hover:text-cyan-glow transition-colors" />
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{cartCount}
</span>
)}
</button>
<Link
to="/shop"
className="glass px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow hover:text-night-900 transition-all duration-300"
className="px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-spa-forest text-white hover:bg-spa-stone transition-all duration-300 shadow-sm"
>
{t('nav.shopNow')}
</Link>
<button
onClick={toggleLanguage}
className="glass px-3 py-2 rounded-full text-xs font-bold hover:bg-white/10 transition-all text-cyan-glow border border-cyan-glow/20"
className="px-3 py-2 rounded-full text-xs font-bold transition-all text-spa-forest border border-spa-forest/30 hover:bg-spa-sage bg-white"
>
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button>
@@ -171,17 +171,17 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<img
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
className="w-9 h-9 rounded-full border-2 border-cyan-glow/50 cursor-pointer object-cover"
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 cursor-pointer object-cover"
/>
<div className="absolute top-full right-0 mt-4 w-32 bg-night-900/95 backdrop-blur-xl border border-white/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
<div className="absolute top-full right-0 mt-4 w-32 bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-xl">
<div className="p-1">
<div className="px-3 py-2 border-b border-white/10 mb-1">
<p className="text-[10px] text-slate-400 uppercase tracking-widest leading-tight truncate">Signed in as</p>
<p className="text-xs text-white font-bold truncate">{customer.displayName}</p>
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">Signed in as</p>
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
</div>
<button
onClick={logoutLiff}
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition-colors"
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
>
Logout
</button>
@@ -191,7 +191,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
) : (
<button
onClick={loginLiff}
className="glass px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest hover:bg-[#00B900] hover:text-white transition-all duration-300 border border-[#00B900]/50 text-[#00B900]"
className="px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
>
LINE Login
</button>
@@ -206,13 +206,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
alt="Profile"
onClick={() => setMobileOpen(true)}
className="w-7 h-7 rounded-full border-2 border-cyan-glow/50 object-cover cursor-pointer"
className="w-7 h-7 rounded-full border-2 border-spa-forest/30 object-cover cursor-pointer"
/>
</div>
) : (
<button
onClick={loginLiff}
className="p-1.5 rounded-full glass hover:bg-[#00B900]/20 transition-all border border-[#00B900]/50 text-[#00B900]"
className="p-1.5 rounded-full text-[#00B900] hover:bg-[#00B900]/10 transition-all border border-[#00B900]/30 bg-white"
aria-label="LINE Login"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
@@ -224,7 +224,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<Link
to="/track"
onClick={() => setMobileOpen(false)}
className="relative p-1.5 rounded-full glass hover:bg-white/10 transition-all"
className="relative p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
aria-label="Track Orders"
>
<MapPin className="w-4 h-4" />
@@ -234,7 +234,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</Link>
<button
onClick={() => { setSearchOpen(true); setMobileOpen(false) }}
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
aria-label="Search"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -244,12 +244,12 @@ export default function Navbar({ categories = [] }: NavbarProps) {
<button
onClick={() => { toggleCart(true); setMobileOpen(false) }}
className="relative p-1.5 rounded-full glass hover:bg-white/10 transition-all"
className="relative p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
aria-label="Cart"
>
<ShoppingBag className="w-4 h-4" />
{cartCount > 0 && (
<span className="absolute -top-1 -right-1 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{cartCount}
</span>
)}
@@ -257,14 +257,14 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Hamburger */}
<button
onClick={() => setMobileOpen(prev => !prev)}
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
<div className="w-4 h-4 flex flex-col justify-center gap-[4px] relative">
<span className={`block w-4 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'opacity-0 w-0' : 'w-3'}`} />
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute -rotate-45 w-4' : 'w-4'}`} />
</div>
{mobileOpen ? (
<X className="w-5 h-5" />
) : (
<MenuIcon className="w-5 h-5" />
)}
</button>
</div>
</nav>
@@ -275,24 +275,24 @@ export default function Navbar({ categories = [] }: NavbarProps) {
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-night-900/80 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? 'opacity-100' : 'opacity-0'}`}
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={() => setMobileOpen(false)}
/>
{/* Drawer */}
<div
className={`absolute top-0 right-0 h-full w-72 bg-night-900/98 backdrop-blur-2xl border-l border-white/10 flex flex-col transition-transform duration-300 ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`}
className={`absolute top-0 right-0 h-full w-72 bg-white/98 backdrop-blur-2xl border-l border-spa-stone/10 flex flex-col transition-transform duration-300 shadow-2xl ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`}
>
{/* Drawer Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5">
<span className="text-sm font-bold uppercase tracking-widest text-slate-300">{t('nav.menu')}</span>
<div className="flex items-center justify-between px-6 py-5 border-b border-spa-stone/10">
<span className="text-sm font-bold uppercase tracking-widest text-spa-forest">{t('nav.menu')}</span>
<div className="flex items-center gap-3">
{customer && (
<img src={customer.pictureUrl || ''} className="w-6 h-6 rounded-full border border-cyan-glow/50" />
<img src={customer.pictureUrl || ''} className="w-6 h-6 rounded-full border-2 border-spa-forest/30" />
)}
<button onClick={toggleLanguage} className="glass px-2.5 py-1 rounded-md text-[10px] font-bold text-cyan-glow border border-cyan-glow/20 uppercase">
<button onClick={toggleLanguage} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-spa-forest border border-spa-forest/30 uppercase bg-spa-sage">
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button>
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-full glass hover:bg-white/10 transition-all">
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -318,7 +318,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${
isActive ? 'bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20' : 'hover:bg-white/5 text-slate-300 hover:text-white'
isActive ? 'bg-spa-forest text-white' : 'text-spa-stone hover:bg-spa-sage hover:text-spa-forest'
}`
}
>
@@ -329,16 +329,16 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Collections sub-section */}
{categories.length > 0 && (
<div className="pt-4">
<p className="text-[10px] uppercase tracking-widest text-slate-600 px-4 pb-2">{t('nav.collections')}</p>
<p className="text-[10px] uppercase tracking-widest text-spa-stone/60 px-4 pb-2 font-semibold">{t('nav.collections')}</p>
{categories.map(cat => (
<Link
key={cat.id}
to={`/category/${cat.id}`}
onClick={() => setMobileOpen(false)}
className="flex items-center justify-between px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
className="flex items-center justify-between px-4 py-2.5 rounded-xl text-sm text-spa-stone hover:text-spa-forest hover:bg-spa-sage transition-all"
>
{cat.name}
<svg className="w-3 h-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-3 h-3 text-spa-stone/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
@@ -348,18 +348,18 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</div>
{/* Drawer Footer CTA */}
<div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3">
<div className="px-4 pb-8 pt-4 border-t border-spa-stone/10 space-y-3">
<Link
to="/shop"
onClick={() => setMobileOpen(false)}
className="block w-full py-3 bg-white text-night-900 font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-cyan-glow transition-colors duration-300"
className="block w-full py-3 bg-spa-forest text-white font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors duration-300"
>
{t('nav.shopNow')}
</Link>
<Link
to="/scent-guide"
onClick={() => setMobileOpen(false)}
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-white/10 transition-colors border border-white/10"
className="block w-full py-3 text-center rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-spa-sage transition-colors border-2 border-spa-forest text-spa-forest"
>
{t('nav.findMyScent')}
</Link>
@@ -386,13 +386,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* ── SEARCH OVERLAY ── */}
{searchOpen && (
<div
className="fixed inset-0 z-[100] bg-night-900/95 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
className="fixed inset-0 z-[100] bg-spa-light/97 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
onClick={(e) => { if (e.target === e.currentTarget) setSearchOpen(false) }}
>
<div className="w-full max-w-2xl space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<p className="text-slate-400 text-xs uppercase tracking-widest">{t('nav.search.title')}</p>
<button onClick={() => setSearchOpen(false)} className="glass p-2 rounded-full hover:bg-white/10 transition-all">
<p className="text-spa-stone text-xs uppercase tracking-widest font-semibold">{t('nav.search.title')}</p>
<button onClick={() => setSearchOpen(false)} className="p-2 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -400,26 +400,26 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</div>
<form onSubmit={handleSearch}>
<div className="flex glass border border-white/20 focus-within:border-cyan-glow/50 rounded-2xl overflow-hidden transition-colors">
<svg className="w-5 h-5 text-slate-400 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="flex bg-white border-2 border-spa-stone/20 focus-within:border-spa-forest rounded-2xl overflow-hidden transition-colors shadow-sm">
<svg className="w-5 h-5 text-spa-stone/50 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search scents, categories..."
className="bg-transparent border-none outline-none text-lg md:text-xl p-4 pl-0 w-full text-white placeholder-slate-600"
placeholder="ค้นหากลิ่น, หมวดหมู่..."
className="bg-transparent border-none outline-none text-lg md:text-xl p-4 pl-0 w-full text-spa-forest placeholder-spa-stone/40"
autoFocus
/>
<button type="submit" className="bg-cyan-glow text-night-900 px-4 md:px-6 font-bold text-xs uppercase tracking-widest shrink-0 hover:bg-white transition-colors">
<button type="submit" className="bg-spa-forest text-white px-4 md:px-6 font-bold text-xs uppercase tracking-widest shrink-0 hover:bg-spa-stone transition-colors">
Go
</button>
</div>
</form>
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-3">Quick Links</p>
<p className="text-xs text-spa-stone/60 uppercase tracking-widest mb-3 font-semibold">Quick Links</p>
<div className="flex flex-wrap gap-2">
{[
...categories.map(c => ({ label: c.name, to: `/category/${c.id}` })),
@@ -430,7 +430,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
key={item.to}
to={item.to}
onClick={() => setSearchOpen(false)}
className="glass px-4 py-1.5 rounded-full text-xs font-medium hover:bg-white/10 hover:text-cyan-glow transition-all border border-white/5"
className="px-4 py-1.5 rounded-full text-xs font-medium text-spa-stone hover:bg-spa-forest hover:text-white transition-all border border-spa-stone/20 bg-white"
>
{item.label}
</Link>
+23 -34
View File
@@ -8,56 +8,45 @@ interface ProductCardProps {
export default function ProductCard({ product }: ProductCardProps) {
return (
<div className="glass glass-hover rounded-2xl overflow-hidden flex flex-col group p-1.5 md:p-2">
<div className="h-40 md:h-64 overflow-hidden rounded-xl relative">
<Link
to={`/products/${product.id}`}
className="bg-white shadow-sm border border-spa-stone/10 rounded-2xl overflow-hidden flex flex-col group p-2 transition-all duration-300 hover:shadow-lg hover:border-spa-forest/30"
>
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
<img
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name}
/>
{/* Category badge */}
<div className="absolute top-4 left-4 glass px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow">
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm">
{product.category.name}
</div>
{/* Available badge */}
<div className="hidden md:block absolute top-4 right-4 glass px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest">
<div className="absolute top-3 right-3 bg-spa-forest/90 backdrop-blur-sm px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-widest text-white shadow-sm">
Available
</div>
</div>
<div className="p-3 md:p-6 space-y-2 md:space-y-4 flex flex-col flex-1">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-1">
<div>
<h3 className="text-sm md:text-xl font-bold leading-tight line-clamp-1">
{product.scent.name}
</h3>
<p className="text-[10px] md:text-xs text-slate-400 mt-0.5 md:mt-1 uppercase tracking-widest line-clamp-1">
{product.category.name}
</p>
</div>
<span className="text-cyan-glow font-bold text-xs md:text-lg">
<div className="p-4 flex flex-col flex-1">
<div className="mb-4">
<h3 className="text-base md:text-lg font-bold text-pop-black leading-tight line-clamp-1 group-hover:text-spa-forest transition-colors">
{product.scent.name}
</h3>
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
{product.category.name}
</p>
</div>
<div className="mt-auto flex items-center justify-between">
<span className="text-rose-500 font-black text-sm md:text-lg">
{formatPrice(product.variants[0]?.price)}
</span>
<span className="text-[10px] md:text-xs font-bold text-spa-forest uppercase tracking-widest flex items-center gap-1 group-hover:translate-x-1 transition-transform">
View Details &rarr;
</span>
</div>
{/* Variants (sizes) */}
<div className="hidden md:flex flex-wrap gap-1.5 md:gap-2">
{product.variants.map((v) => (
<div key={v.id} className="glass px-1.5 py-0.5 md:px-2.5 md:py-1 rounded-lg text-[8px] md:text-[10px] uppercase tracking-wider">
{v.size}
</div>
))}
</div>
<div className="flex-1" />
<Link
to={`/products/${product.id}`}
className="block w-full py-2 md:py-3 text-center bg-white/10 hover:bg-white text-white hover:text-night-900 rounded-lg font-bold uppercase tracking-widest text-[10px] md:text-xs transition-all duration-300"
>
Discover
</Link>
</div>
</div>
</Link>
)
}
+7
View File
@@ -11,6 +11,13 @@
--color-pop-bg: #FFF0F5;
--color-pop-black: #333333;
/* Spa Theme Colors */
--color-spa-light: #FDFDF9;
--color-spa-sage: #EAEFEA;
--color-spa-stone: #4B4B46;
--color-spa-forest: #1F3D2D;
--color-spa-gold: #D4AF37;
--shadow-brutal: 0 10px 25px rgba(0,0,0,0.05);
--shadow-brutal-hover: 0 20px 40px rgba(0,0,0,0.1);
+15 -14
View File
@@ -85,26 +85,27 @@ export default function CategoryPage() {
alt={category?.name}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-night-900 via-night-900/60 to-transparent" />
<div className="absolute inset-0 bg-night-900/40" />
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
<div className="max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest mb-4">
<div className="flex items-center gap-2 text-xs text-spa-sage/80 uppercase tracking-widest mb-4 font-semibold">
<Link to="/" className="hover:text-white transition-colors">Home</Link>
<span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<span>/</span>
<span className="text-cyan-glow">{category?.name ?? '...'}</span>
<span className="text-white">{category?.name ?? '...'}</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold">
<h1 className="text-4xl md:text-5xl font-bold text-white">
{loading ? (
<div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg" />
<div className="h-10 w-48 bg-white/20 animate-pulse rounded-lg" />
) : (
category?.name
)}
</h1>
{!loading && category?._count && (
<p className="text-slate-400 mt-2">
<p className="text-spa-sage mt-2">
{category._count.products} products available
</p>
)}
@@ -120,10 +121,10 @@ export default function CategoryPage() {
<Link
key={cat.id}
to={`/category/${cat.id}`}
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
cat.id === category?.id
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
: 'glass hover:bg-white/10'
? 'bg-spa-forest text-white border-spa-forest shadow-md'
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{cat.name}
@@ -134,19 +135,19 @@ export default function CategoryPage() {
{error ? (
<div className="text-center py-24 space-y-3">
<p className="text-4xl">⚠️</p>
<p className="text-slate-400">{error}</p>
<Link to="/shop" className="text-cyan-glow text-sm hover:underline">
<p className="text-spa-stone font-medium">{error}</p>
<Link to="/shop" className="text-spa-forest font-bold text-sm hover:underline">
Back to Shop
</Link>
</div>
) : loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
<div key={i} className="rounded-2xl bg-spa-stone/10 animate-pulse h-96" />
))}
</div>
) : products.length === 0 ? (
<div className="text-center py-24 text-slate-500 space-y-3">
<div className="text-center py-24 text-spa-stone/60 space-y-3 font-medium">
<p className="text-4xl">🌿</p>
<p>No products in this category yet.</p>
</div>
@@ -161,7 +162,7 @@ export default function CategoryPage() {
{/* Invisible target for IntersectionObserver */}
{displayCount < products.length && (
<div ref={loaderRef} className="py-12 flex justify-center">
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
<div className="w-8 h-8 rounded-full border-2 border-spa-forest border-t-transparent animate-spin" />
</div>
)}
</div>
+41 -35
View File
@@ -219,61 +219,67 @@ export default function CheckoutPage() {
}
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24">
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 text-pop-black">
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
<div className="flex flex-col lg:flex-row gap-12">
{/* Form Section */}
<div className="flex-1 space-y-8">
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Shipping Information</h2>
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Shipping Information</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
<label htmlFor="checkout-name" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Full Name *</label>
<input
id="checkout-name"
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
placeholder="Jane Doe"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Phone Number *</label>
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Phone Number *</label>
<input
id="checkout-phone"
type="tel"
required
pattern="[0-9]{9,10}"
title="Please enter a valid phone number (9-10 digits)"
value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
placeholder="0812345678"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Email Address *</label>
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Email Address *</label>
<input
id="checkout-email"
type="email"
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
placeholder="jane@example.com"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Shipping Address *</label>
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Shipping Address *</label>
<textarea
id="checkout-address"
required
rows={3}
value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all resize-none"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all resize-none placeholder:text-spa-stone/50"
placeholder="123 Serenity Street..."
/>
</div>
@@ -284,84 +290,84 @@ export default function CheckoutPage() {
{/* Summary Section */}
<div className="lg:w-96 space-y-6">
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 sticky top-32">
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4 mb-6">Order Summary</h2>
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 shadow-sm sticky top-32">
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4 mb-6">Order Summary</h2>
{/* Coupon Section */}
<div className="mb-6 pb-6 border-b border-white/10">
<div className="mb-6 pb-6 border-b border-spa-stone/10">
{!appliedCoupon ? (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Discount Code</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">Discount Code</label>
<div className="flex gap-2">
<input
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="e.g. SUMMER2026"
className="flex-1 bg-night-950 border border-white/10 rounded-lg px-3 text-sm text-white focus:border-cyan-glow outline-none uppercase tracking-widest"
className="flex-1 bg-spa-light border border-spa-stone/20 rounded-lg px-3 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
/>
<button
type="button"
onClick={handleApplyCoupon}
disabled={couponLoading || !couponCode.trim()}
className="px-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all"
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
>
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
</button>
</div>
{couponError && <p className="text-xs text-rose-500 mt-1">{couponError}</p>}
{couponError && <p className="text-xs text-rose-500 mt-1 font-medium">{couponError}</p>}
</div>
) : (
<div className="flex items-center justify-between bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg px-4 py-3">
<div className="flex items-center justify-between bg-spa-sage/30 border border-spa-forest/20 rounded-lg px-4 py-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4 text-spa-forest" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-bold tracking-widest text-cyan-glow">{appliedCoupon}</span>
<span className="text-sm font-bold tracking-widest text-spa-forest">{appliedCoupon}</span>
</div>
<button type="button" onClick={removeCoupon} className="text-xs text-slate-400 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
</div>
)}
</div>
<div className="space-y-4 mb-6">
{items.map(item => (
<div key={item.cartItemId} className="flex justify-between items-start gap-4 text-sm">
<div key={item.cartItemId} className="flex justify-between items-start gap-4 text-sm text-pop-black">
<div className="flex-1">
<p className="font-bold">{item.productName}</p>
<p className="text-slate-400 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
<p className="text-spa-stone/80 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
</div>
<p className="font-medium text-cyan-glow">{formatPrice(item.price * item.quantity)}</p>
<p className="font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</p>
</div>
))}
</div>
<div className="border-t border-white/10 pt-4 space-y-3 font-medium">
<div className="flex justify-between text-sm text-slate-300">
<div className="border-t border-spa-stone/10 pt-4 space-y-3 font-medium text-pop-black">
<div className="flex justify-between text-sm text-spa-stone">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-slate-300">
<div className="flex justify-between text-sm text-spa-stone">
<span>Shipping Fee</span>
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
<span className={isFreeShipping ? "line-through text-slate-500" : ""}>{formatPrice(shippingFee)}</span>
<span className={isFreeShipping ? "line-through text-spa-stone/50" : ""}>{formatPrice(shippingFee)}</span>
}
</div>
{isFreeShipping && (
<div className="flex justify-between text-sm text-emerald-400">
<div className="flex justify-between text-sm text-spa-forest">
<span>Shipping Discount</span>
<span>- {formatPrice(shippingFee)}</span>
</div>
)}
{discountAmount > 0 && (
<div className="flex justify-between text-sm text-cyan-glow">
<div className="flex justify-between text-sm text-rose-500">
<span>Discount</span>
<span>- {formatPrice(discountAmount)}</span>
</div>
)}
<div className="border-t border-white/10 pt-3 mt-3 flex justify-between text-lg font-bold">
<div className="border-t border-spa-stone/10 pt-3 mt-3 flex justify-between text-lg font-bold text-pop-black">
<span>Total</span>
<span className="text-cyan-glow">{formatPrice(total)}</span>
<span className="text-rose-500">{formatPrice(total)}</span>
</div>
</div>
@@ -369,7 +375,7 @@ export default function CheckoutPage() {
form="checkout-form"
type="submit"
disabled={loading || loadingSettings}
className="w-full mt-8 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center gap-2"
className="w-full mt-8 py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center gap-2"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm Order'}
</button>
+24 -28
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category } from '../types'
import { Ticket, Copy, CheckCircle2, ArrowRight } from 'lucide-react'
@@ -61,15 +62,15 @@ export default function CouponsPage() {
}
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
<div className="text-center space-y-4 mb-16">
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-white via-cyan-glow to-blue-500 bg-clip-text text-transparent">
<h1 className="text-4xl md:text-5xl font-bold text-pop-black">
Exclusive Offers
</h1>
<p className="text-slate-400 max-w-2xl mx-auto">
<p className="text-spa-stone max-w-2xl mx-auto">
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
</p>
</div>
@@ -77,63 +78,58 @@ export default function CouponsPage() {
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-64 bg-white/5 rounded-3xl animate-pulse" />
<div key={i} className="h-64 bg-spa-stone/10 rounded-3xl animate-pulse" />
))}
</div>
) : coupons.length === 0 ? (
<div className="text-center py-20 bg-night-900/50 rounded-3xl border border-white/5">
<Ticket className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">ยังไม่มีคูปองส่วนลดในขณะนี้</h2>
<p className="text-slate-400 mb-6">กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง</p>
<button
onClick={() => navigate('/shop')}
className="px-8 py-3 bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow hover:text-night-900 font-bold rounded-xl transition-all"
>
ไปช้อปปิ้งกันก่อน
</button>
</div>
<EmptyState
title="ยังไม่มีคูปองส่วนลดในขณะนี้"
message="กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง"
actionText="ไปช้อปปิ้งกันก่อน"
actionLink="/shop"
/>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{coupons.map(coupon => (
<div
key={coupon.id}
className="group relative bg-night-900 border border-white/10 rounded-3xl overflow-hidden hover:border-cyan-glow/50 transition-all duration-500"
className="group relative bg-white border border-spa-stone/10 rounded-3xl overflow-hidden hover:border-spa-forest transition-all duration-500 shadow-sm hover:shadow-md"
>
{/* Decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="absolute top-0 right-0 w-32 h-32 bg-spa-sage/20 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="p-8 space-y-6 relative z-10">
<div className="flex items-start justify-between">
<div className="w-12 h-12 rounded-xl bg-cyan-glow/10 text-cyan-glow flex items-center justify-center border border-cyan-glow/20">
<div className="w-12 h-12 rounded-xl bg-spa-sage/30 text-spa-forest flex items-center justify-center border border-spa-stone/10">
<Ticket className="w-6 h-6" />
</div>
{coupon.expiresAt && (
<span className="text-[10px] font-bold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md uppercase tracking-wider">
<span className="text-[10px] font-bold text-rose-500 bg-rose-500/10 px-2 py-1 rounded-md uppercase tracking-wider">
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
</span>
)}
</div>
<div>
<h3 className="text-3xl font-black text-white mb-1">
<h3 className="text-3xl font-black text-pop-black mb-1">
{formatDiscount(coupon.type, coupon.value)}
</h3>
<p className="text-sm text-slate-400">
<p className="text-sm text-spa-stone">
เมื่อซื้อครบ {coupon.minPurchase} บาท
</p>
</div>
<div className="flex items-center gap-2 bg-night-950 p-1 pl-4 rounded-xl border border-white/5">
<span className="flex-1 font-mono text-cyan-glow font-bold tracking-widest uppercase">
<div className="flex items-center gap-2 bg-spa-light p-1 pl-4 rounded-xl border border-spa-stone/20">
<span className="flex-1 font-mono text-spa-forest font-bold tracking-widest uppercase">
{coupon.code}
</span>
<button
onClick={() => handleCopy(coupon.code)}
className="p-3 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-all group/btn"
className="p-3 hover:bg-spa-stone/10 rounded-lg text-spa-stone hover:text-spa-forest transition-all group/btn"
title="Copy Code"
>
{copiedCode === coupon.code ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
) : (
<Copy className="w-5 h-5 group-hover/btn:scale-110 transition-transform" />
)}
@@ -142,7 +138,7 @@ export default function CouponsPage() {
<button
onClick={() => handleUseNow(coupon.code)}
className="w-full py-4 bg-white/5 hover:bg-cyan-glow text-white hover:text-night-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-all duration-300"
className="w-full py-4 bg-spa-forest text-white hover:bg-spa-stone font-bold rounded-xl flex items-center justify-center gap-2 transition-all duration-300 shadow-sm"
>
ใช้คูปองนี้เลย
<ArrowRight className="w-4 h-4" />
@@ -150,8 +146,8 @@ export default function CouponsPage() {
</div>
{/* Scalloped edge effect */}
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-r border-white/10" />
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-l border-white/10" />
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-pop-bg rounded-full border-r border-spa-stone/10" />
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-6 h-6 bg-pop-bg rounded-full border-l border-spa-stone/10" />
</div>
))}
</div>
+19 -14
View File
@@ -42,31 +42,33 @@ export default function HomePage() {
<Navbar categories={categories} />
{/* Hero Section */}
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-white">
<div className="absolute inset-0 z-0 opacity-40">
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-night-900">
<div className="absolute inset-0 z-0">
<img
src={hero?.imageUrl || "/images/hero.png"}
className="w-full h-full object-cover scale-[1.02] filter contrast-125 saturate-150"
className="w-full h-full object-cover scale-[1.02]"
alt="Hero Background"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div className="absolute inset-0 bg-gradient-to-r from-pop-bg/80 to-transparent"></div>
{/* Dark gradient overlay for premium vibe */}
<div className="absolute inset-0 bg-night-900/40"></div>
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-night-900/30"></div>
</div>
<div className="relative z-10 text-center md:text-left space-y-6 px-8 animate-pop-in max-w-6xl w-full mx-auto">
<p className="text-pop-pink font-semibold tracking-[0.2em] text-sm uppercase">
<div className="relative z-10 text-center space-y-6 px-8 animate-fade-in max-w-4xl w-full mx-auto mt-16 md:mt-0">
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
{hero?.tagline || "Vibrant Elegance Collection"}
</p>
<h1 className="text-5xl md:text-7xl font-bold text-pop-black leading-[1.1] tracking-tight max-w-3xl">
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
{hero ? (
hero.title.split('[').map((part, i) => {
if (part.includes(']')) {
const [accent, rest] = part.split(']')
return (
<span key={i}>
<span className="text-pop-pink font-semibold">
<span className="text-spa-gold font-medium italic">
{accent}
</span>
{rest}
@@ -77,25 +79,28 @@ export default function HomePage() {
})
) : (
<>
Inhale <span className="text-pop-pink">Serenity</span>
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
</>
)}
</h1>
<p className="text-gray-600 max-w-xl text-lg md:text-xl font-normal pt-4 pb-6">
<p className="text-spa-light/90 max-w-2xl mx-auto text-lg md:text-xl font-light pt-4 pb-8 drop-shadow-md">
{hero?.description || "Crafting premium sensory experiences that transform your space into a sanctuary of peace."}
</p>
<div className="flex flex-col sm:flex-row gap-4 items-center md:justify-start justify-center">
<div className="flex flex-col sm:flex-row gap-6 items-center justify-center">
{/* White solid primary button */}
<Link
to={hero?.buttonLink || "/shop"}
className="brutal-button shadow-lg shadow-pop-pink/30"
className="px-8 py-4 bg-white text-spa-forest rounded-full font-bold uppercase tracking-widest text-sm hover:bg-spa-sage hover:shadow-xl transition-all duration-300"
>
{hero?.buttonText || "Explore Collection"}
</Link>
{/* Outline secondary link */}
<a
href="#categories"
className="px-8 py-4 text-pop-black font-medium hover:text-pop-pink transition-colors"
className="px-8 py-4 text-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
>
Learn More
Learn More
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span>
</a>
</div>
</div>
+49 -49
View File
@@ -73,10 +73,10 @@ export default function OrderSuccessPage() {
if (loading) {
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<div className="flex items-center justify-center pt-40">
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
<Loader2 className="w-8 h-8 text-spa-forest animate-spin" />
</div>
</div>
)
@@ -84,119 +84,119 @@ export default function OrderSuccessPage() {
if (!order || (order as any).error) {
return (
<div className="min-h-screen text-center pt-40">
<div className="min-h-screen bg-pop-bg text-center pt-40">
<Navbar categories={categories} />
<h1 className="text-2xl font-bold mb-4">Order Not Found</h1>
<Link to="/shop" className="text-cyan-glow hover:underline">Return to Shop</Link>
<h1 className="text-2xl font-bold mb-4 text-pop-black">Order Not Found</h1>
<Link to="/shop" className="text-spa-forest hover:underline font-bold">Return to Shop</Link>
</div>
)
}
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in">
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in text-pop-black">
{/* Header */}
<div className="text-center space-y-6">
<div className="w-20 h-20 bg-cyan-glow/10 rounded-full flex items-center justify-center mx-auto border border-cyan-glow/20">
<CheckCircle2 className="w-10 h-10 text-cyan-glow" />
<div className="w-20 h-20 bg-spa-sage/30 rounded-full flex items-center justify-center mx-auto border border-spa-stone/10 shadow-sm">
<CheckCircle2 className="w-10 h-10 text-spa-forest" />
</div>
<div>
<h1 className="text-4xl font-bold">Order Received</h1>
<p className="text-slate-400 mt-2">Thank you! Your order #{order.id.toString().padStart(4, '0')} has been placed.</p>
<h1 className="text-4xl font-bold text-pop-black">Order Received</h1>
<p className="text-spa-stone mt-2">Thank you! Your order #{order.id.toString().padStart(4, '0')} has been placed.</p>
</div>
</div>
{/* Order Summary */}
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
<h2 className="text-sm font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Order Items</h2>
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
<h2 className="text-sm font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Order Items</h2>
<div className="space-y-4">
{order.items.map(item => (
<div key={item.id} className="flex items-center gap-4 text-sm">
<div className="w-12 h-12 rounded-lg bg-white/5 flex-shrink-0 overflow-hidden">
<div className="w-12 h-12 rounded-lg bg-spa-stone/10 flex-shrink-0 overflow-hidden border border-spa-stone/10">
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<p className="font-bold">{item.productName}</p>
<p className="text-slate-400 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
<p className="font-bold text-pop-black">{item.productName}</p>
<p className="text-spa-stone/80 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
</div>
<p className="font-medium text-cyan-glow">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
<p className="font-bold text-rose-500">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
</div>
))}
</div>
<div className="border-t border-white/10 pt-4 space-y-3 font-medium text-sm text-slate-300">
<div className="flex justify-between">
<div className="border-t border-spa-stone/10 pt-4 space-y-3 font-medium text-sm text-pop-black">
<div className="flex justify-between text-spa-stone">
<span>Subtotal</span>
<span>{formatPrice(order.totalAmount - order.shippingFee)}</span>
</div>
<div className="flex justify-between">
<div className="flex justify-between text-spa-stone">
<span>Shipping Fee</span>
<span>{formatPrice(order.shippingFee)}</span>
</div>
<div className="border-t border-white/10 pt-3 mt-3 flex justify-between text-lg font-bold text-white">
<div className="border-t border-spa-stone/10 pt-3 mt-3 flex justify-between text-lg font-bold text-pop-black">
<span>Total</span>
<span className="text-cyan-glow">{formatPrice(order.totalAmount)}</span>
<span className="text-rose-500">{formatPrice(order.totalAmount)}</span>
</div>
</div>
</div>
{/* Banking Details */}
{settings && order.status === 'PENDING_PAYMENT' && (
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 text-center space-y-6 relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-cyan-glow to-transparent opacity-50" />
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 text-center space-y-6 relative overflow-hidden shadow-sm">
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-spa-forest to-transparent opacity-50" />
<h2 className="text-xs uppercase font-bold tracking-widest text-slate-400">Payment Required</h2>
<div className="text-3xl font-bold text-cyan-glow">
<h2 className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Payment Required</h2>
<div className="text-3xl font-bold text-spa-forest">
{formatPrice(order.totalAmount)}
</div>
<div className="bg-night-950 p-6 rounded-xl border border-white/5 inline-block text-left w-full max-w-md mx-auto space-y-4">
<div className="bg-spa-light p-6 rounded-xl border border-spa-stone/20 inline-block text-left w-full max-w-md mx-auto space-y-4 shadow-inner">
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest">Bank</p>
<p className="font-bold">{settings.bankName}</p>
<p className="text-xs text-spa-stone/80 uppercase tracking-widest font-bold">Bank</p>
<p className="font-bold text-pop-black">{settings.bankName}</p>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest">Account Number</p>
<p className="font-mono text-xl font-bold tracking-widest text-cyan-glow">{settings.bankAccountNumber}</p>
<p className="text-xs text-spa-stone/80 uppercase tracking-widest font-bold">Account Number</p>
<p className="font-mono text-xl font-bold tracking-widest text-spa-forest">{settings.bankAccountNumber}</p>
</div>
<button
onClick={copyToClipboard}
className="p-2 glass rounded-lg hover:bg-white/10 transition-colors shrink-0"
className="p-2 bg-white rounded-lg hover:bg-spa-stone/10 border border-spa-stone/20 transition-colors shrink-0 shadow-sm"
title="Copy account number"
>
{copied ? <CheckCircle2 className="w-5 h-5 text-emerald-400" /> : <Copy className="w-5 h-5 text-slate-300" />}
{copied ? <CheckCircle2 className="w-5 h-5 text-emerald-500" /> : <Copy className="w-5 h-5 text-spa-stone/80" />}
</button>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest">Account Name</p>
<p className="font-bold">{settings.bankAccountName}</p>
<p className="text-xs text-spa-stone/80 uppercase tracking-widest font-bold">Account Name</p>
<p className="font-bold text-pop-black">{settings.bankAccountName}</p>
</div>
</div>
<p className="text-xs text-slate-500">Please transfer the exact amount and upload your slip below.</p>
<p className="text-xs text-spa-stone font-medium">Please transfer the exact amount and upload your slip below.</p>
</div>
)}
{/* Upload Status */}
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
<h2 className="text-sm font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Payment Slip Verification</h2>
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
<h2 className="text-sm font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Payment Slip Verification</h2>
{order.paymentSlipUrl ? (
<div className="flex flex-col items-center justify-center space-y-4 py-4">
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-emerald-400" />
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center border border-emerald-500/20">
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
</div>
<div className="text-center">
<p className="font-bold text-lg text-emerald-400">Slip Uploaded Successfully</p>
<p className="text-sm text-slate-400 mt-1">Status: {order.status}</p>
<p className="text-xs mt-2 text-slate-500">We will verify your payment and process your order shortly.</p>
<p className="font-bold text-lg text-emerald-600">Slip Uploaded Successfully</p>
<p className="text-sm text-spa-stone mt-1 font-medium">Status: {order.status}</p>
<p className="text-xs mt-2 text-spa-stone/80">We will verify your payment and process your order shortly.</p>
</div>
<img src={order.paymentSlipUrl} alt="Slip" className="w-48 rounded-xl border border-white/10 mt-4 opacity-50 hover:opacity-100 transition-opacity" />
<img src={order.paymentSlipUrl} alt="Slip" className="w-48 rounded-xl border border-spa-stone/20 mt-4 opacity-80 hover:opacity-100 transition-opacity shadow-sm" />
</div>
) : (
<div className="relative border-2 border-dashed border-white/10 rounded-xl p-10 hover:border-cyan-glow/50 transition-colors group cursor-pointer overflow-hidden">
<div className="relative border-2 border-dashed border-spa-stone/30 bg-spa-light rounded-xl p-10 hover:border-spa-forest transition-colors group cursor-pointer overflow-hidden">
<input
type="file"
accept="image/*"
@@ -206,13 +206,13 @@ export default function OrderSuccessPage() {
/>
<div className="text-center space-y-4">
{uploading ? (
<Loader2 className="w-12 h-12 text-cyan-glow animate-spin mx-auto" />
<Loader2 className="w-12 h-12 text-spa-forest animate-spin mx-auto" />
) : (
<UploadCloud className="w-12 h-12 text-slate-500 group-hover:text-cyan-glow transition-colors mx-auto" />
<UploadCloud className="w-12 h-12 text-spa-stone/60 group-hover:text-spa-forest transition-colors mx-auto" />
)}
<div>
<p className="font-bold">{uploading ? 'Uploading...' : 'Click or drag to upload slip'}</p>
<p className="text-xs text-slate-500 mt-1">Supports JPG, PNG (Max 5MB)</p>
<p className="font-bold text-pop-black">{uploading ? 'Uploading...' : 'Click or drag to upload slip'}</p>
<p className="text-xs text-spa-stone font-medium mt-1">Supports JPG, PNG (Max 5MB)</p>
</div>
</div>
</div>
+53 -53
View File
@@ -50,16 +50,16 @@ export default function ProductDetailPage() {
if (loading) {
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<div className="pt-32 px-6 max-w-7xl mx-auto animate-pulse space-y-8">
<div className="h-6 w-48 bg-white/5 rounded" />
<div className="h-6 w-48 bg-spa-stone/10 rounded" />
<div className="grid md:grid-cols-2 gap-12">
<div className="h-96 bg-white/5 rounded-2xl" />
<div className="h-96 bg-spa-stone/10 rounded-2xl" />
<div className="space-y-4">
<div className="h-8 w-3/4 bg-white/5 rounded" />
<div className="h-4 w-1/2 bg-white/5 rounded" />
<div className="h-12 w-1/3 bg-white/5 rounded" />
<div className="h-8 w-3/4 bg-spa-stone/10 rounded" />
<div className="h-4 w-1/2 bg-spa-stone/10 rounded" />
<div className="h-12 w-1/3 bg-spa-stone/10 rounded" />
</div>
</div>
</div>
@@ -70,12 +70,12 @@ export default function ProductDetailPage() {
if (error || !product) {
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
<div className="pt-40 text-center space-y-4">
<p className="text-4xl">⚠️</p>
<p className="text-slate-400">{error ?? 'Product not found'}</p>
<Link to="/shop" className="text-cyan-glow hover:underline text-sm">
<p className="text-spa-stone font-medium">{error ?? 'Product not found'}</p>
<Link to="/shop" className="text-spa-forest hover:underline text-sm font-bold">
Back to Shop
</Link>
</div>
@@ -100,35 +100,35 @@ export default function ProductDetailPage() {
}
return (
<div className="min-h-screen pb-24 md:pb-0">
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
<Navbar categories={categories} />
<div className="max-w-7xl mx-auto px-6 pt-32 pb-24 space-y-24">
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest">
<Link to="/" className="hover:text-white transition-colors">Home</Link>
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
<Link to="/" className="hover:text-spa-forest transition-colors">Home</Link>
<span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<Link to="/shop" className="hover:text-spa-forest transition-colors">Shop</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-white transition-colors">
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{product.category.name}
</Link>
<span>/</span>
<span className="text-cyan-glow">{product.scent.name}</span>
<span className="text-spa-forest">{product.scent.name}</span>
</div>
{/* Product Detail */}
<div className="grid md:grid-cols-2 gap-16 items-center">
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
{/* Image */}
<div className="relative rounded-2xl overflow-hidden h-[480px] group">
<div className="relative rounded-2xl overflow-hidden h-[480px] group bg-white shadow-sm border border-spa-stone/10">
<img
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-night-900/50 to-transparent" />
<div className="absolute top-6 left-6 glass px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow border border-cyan-glow/20">
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute top-6 left-6 bg-white/90 backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm">
{product.category.name}
</div>
</div>
@@ -136,34 +136,34 @@ export default function ProductDetailPage() {
{/* Info */}
<div className="space-y-8">
<div className="space-y-2">
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-80">
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80">
{product.category.name}
</p>
<h1 className="text-5xl font-bold leading-tight">
<h1 className="text-5xl font-bold leading-tight text-pop-black">
{product.scent.name}
</h1>
<p className="text-slate-400 text-sm uppercase tracking-widest">
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
Premium Collection
</p>
</div>
{product.description && (
<p className="text-slate-300 leading-relaxed">{product.description}</p>
<p className="text-spa-stone leading-relaxed">{product.description}</p>
)}
{/* Price */}
<div className="flex items-baseline gap-3">
<span className="text-4xl font-bold text-cyan-glow">
<span className="text-4xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
{currentVariant && (
<span className="text-slate-400 text-sm">/ {currentVariant.size}</span>
<span className="text-spa-stone/60 text-sm font-semibold">/ {currentVariant.size}</span>
)}
</div>
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-slate-400 font-semibold">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
Select Size
</p>
<div className="flex flex-wrap gap-3">
@@ -171,14 +171,14 @@ export default function ProductDetailPage() {
<button
key={v.id}
onClick={() => setSelectedVariant(i)}
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 ${
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 border ${
selectedVariant === i
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.4)]'
: 'glass hover:bg-white/10'
? 'bg-spa-forest text-white border-spa-forest shadow-md'
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{v.size}
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-night-800' : 'text-slate-400'}`}>
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-white/80' : 'text-spa-stone/60'}`}>
{formatPrice(v.price)}
</span>
</button>
@@ -187,14 +187,14 @@ export default function ProductDetailPage() {
</div>
{/* Actions */}
<div className="flex gap-4">
<div className="hidden md:flex gap-4">
<button
onClick={handleAddToCart}
className="flex-1 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500"
className="flex-1 py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
>
Add to Cart
</button>
<button className="glass p-4 rounded-lg hover:bg-white/10 transition-all">
<button className="bg-white border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
@@ -202,16 +202,16 @@ export default function ProductDetailPage() {
</div>
{/* Meta */}
<div className="border-t border-white/5 pt-6 grid grid-cols-2 gap-4 text-sm text-slate-400">
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div>
<span className="text-xs uppercase tracking-widest block mb-1">Scent</span>
<span className="text-white font-medium">{product.scent.name}</span>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Scent</span>
<span className="text-pop-black font-bold">{product.scent.name}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1">Category</span>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Category</span>
<Link
to={`/category/${product.category.id}`}
className="text-white font-medium hover:text-cyan-glow transition-colors"
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
>
{product.category.name}
</Link>
@@ -222,10 +222,10 @@ export default function ProductDetailPage() {
{/* Full Details Section */}
{product.details && (
<div className="pt-16 border-t border-white/5 space-y-6">
<h2 className="text-3xl font-bold">Product Details</h2>
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
<h2 className="text-3xl font-bold text-pop-black">Product Details</h2>
<div
className="prose prose-invert prose-lg max-w-4xl product-editorial"
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
dangerouslySetInnerHTML={{ __html: product.details }}
/>
</div>
@@ -233,17 +233,17 @@ export default function ProductDetailPage() {
{/* Related Products */}
{related.length > 0 && (
<div className="space-y-8 border-t border-white/5 pt-16">
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
<div className="flex justify-between items-end">
<div>
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70 mb-2">
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
More {product.category.name}
</p>
<h2 className="text-3xl font-bold">Related Products</h2>
<h2 className="text-3xl font-bold text-pop-black">Related Products</h2>
</div>
<Link
to={`/category/${product.category.id}`}
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline"
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
>
View All →
</Link>
@@ -260,7 +260,7 @@ export default function ProductDetailPage() {
<Footer categories={categories} />
{/* Mobile Sticky Add to Cart Bar */}
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-night-950/95 backdrop-blur-xl border-t border-white/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.5)]">
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-white/95 backdrop-blur-xl border-t border-spa-stone/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.05)]">
{/* Mobile Variant Selector */}
{product.variants && product.variants.length > 1 && (
@@ -271,8 +271,8 @@ export default function ProductDetailPage() {
onClick={() => setSelectedVariant(i)}
className={`flex-shrink-0 px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap ${
selectedVariant === i
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
: 'bg-white/5 border-white/10 text-slate-400'
? 'bg-spa-forest border-spa-forest text-white'
: 'bg-white border-spa-stone/20 text-spa-stone'
}`}
>
{v.size}
@@ -282,17 +282,17 @@ export default function ProductDetailPage() {
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-slate-400">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold">
{product.variants && product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
</span>
<span className="text-xl font-bold text-cyan-glow">
<span className="text-xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
</span>
</div>
<button
onClick={handleAddToCart}
className="flex-1 py-3.5 bg-cyan-glow text-night-900 font-bold rounded-lg tracking-widest uppercase hover:brightness-110 shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all flex justify-center items-center gap-2"
className="flex-1 py-3.5 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone shadow-sm transition-all flex justify-center items-center gap-2"
>
Add to Cart
</button>
+21 -22
View File
@@ -3,6 +3,7 @@ import { useSearchParams, Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category, type Product } from '../types'
export default function SearchPage() {
@@ -46,20 +47,20 @@ export default function SearchPage() {
}
return (
<div className="min-h-screen">
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
{/* Search Header */}
<div className="pt-32 pb-16 px-6 bg-night-800/20 border-b border-white/5">
<div className="pt-32 pb-16 px-6 bg-spa-light border-b border-spa-stone/10">
<div className="max-w-3xl mx-auto space-y-6 text-center">
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
Discover
</p>
<h1 className="text-5xl font-bold">Search Products</h1>
<h1 className="text-5xl font-bold text-pop-black">Search Products</h1>
<form onSubmit={handleSubmit} className="relative">
<div className="flex glass border border-white/10 rounded-2xl overflow-hidden focus-within:border-cyan-glow/40 transition-colors">
<svg className="w-5 h-5 text-slate-400 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="flex bg-white border border-spa-stone/20 rounded-2xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-5 h-5 text-spa-stone m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
@@ -67,12 +68,12 @@ export default function SearchPage() {
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search by scent name, category..."
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-white placeholder-slate-500"
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-pop-black placeholder-spa-stone/60"
autoFocus
/>
<button
type="submit"
className="bg-white text-night-900 px-8 font-bold text-xs uppercase tracking-widest hover:bg-cyan-glow transition-colors shrink-0"
className="bg-spa-forest text-white px-8 font-bold text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors shrink-0"
>
Search
</button>
@@ -85,7 +86,7 @@ export default function SearchPage() {
<Link
key={cat.id}
to={`/search?q=${encodeURIComponent(cat.name)}`}
className="glass px-4 py-1.5 rounded-full text-xs font-medium hover:border-cyan-glow/30 hover:text-cyan-glow transition-all duration-300"
className="bg-white border border-spa-stone/20 px-4 py-1.5 rounded-full text-xs font-medium text-spa-stone hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all duration-300"
>
{cat.name}
</Link>
@@ -97,34 +98,32 @@ export default function SearchPage() {
{/* Results */}
<div className="max-w-7xl mx-auto px-6 py-16 space-y-8">
{!searched && !query && (
<div className="text-center py-20 space-y-3 text-slate-500">
<div className="text-center py-20 space-y-3 text-spa-stone">
<p className="text-5xl">🔍</p>
<p className="text-lg text-slate-400">Type something to discover our collection</p>
<p className="text-lg font-medium text-pop-black">Type something to discover our collection</p>
</div>
)}
{searched && (
<p className="text-slate-400 text-sm">
<p className="text-spa-stone text-sm font-medium">
{loading ? 'Searching...' : `${products.length} result${products.length !== 1 ? 's' : ''} for `}
{!loading && <span className="text-white font-semibold">"{query}"</span>}
{!loading && <span className="text-pop-black font-bold">"{query}"</span>}
</p>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
<div key={i} className="rounded-2xl bg-spa-stone/10 animate-pulse h-96" />
))}
</div>
) : searched && products.length === 0 ? (
<div className="text-center py-20 space-y-4">
<p className="text-5xl">🌿</p>
<p className="text-xl text-slate-400">No results found</p>
<p className="text-slate-500 text-sm">Try a different keyword or browse our categories</p>
<Link to="/shop" className="inline-block mt-4 px-8 py-3 bg-white/10 hover:bg-white hover:text-night-900 text-white font-bold rounded-lg uppercase tracking-widest text-xs transition-all duration-300">
Browse All Products
</Link>
</div>
<EmptyState
title="No results found"
message="Try a different keyword or browse our categories"
actionText="Browse All Products"
actionLink="/shop"
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{products.map((p) => <ProductCard key={p.id} product={p} />)}
+29 -23
View File
@@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category, type Product } from '../types'
export default function ShopPage() {
@@ -99,19 +100,19 @@ export default function ShopPage() {
<Navbar categories={categories} />
{/* Page Header */}
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-spa-light">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
All Products
</p>
<h1 className="text-5xl font-bold tracking-tight">The Collection</h1>
<p className="text-slate-400 max-w-md mx-auto">
<h1 className="text-5xl font-bold tracking-tight text-pop-black">The Collection</h1>
<p className="text-spa-stone max-w-md mx-auto">
Browse our full range of premium aroma products — crafted to elevate every space.
</p>
{/* Search */}
<div className="max-w-md mx-auto mt-6">
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="flex bg-white border border-spa-stone/20 rounded-xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-4 h-4 text-spa-stone m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
@@ -119,7 +120,7 @@ export default function ShopPage() {
placeholder="Search by scent or category..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-500"
className="bg-transparent border-none outline-none text-sm p-2 w-full text-pop-black placeholder-spa-stone/60"
/>
</div>
</div>
@@ -130,10 +131,10 @@ export default function ShopPage() {
<div className="flex flex-wrap gap-3">
<button
onClick={() => setActiveCategory(null)}
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
activeCategory === null
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
: 'glass hover:bg-white/10'
? 'bg-spa-forest text-white border-spa-forest shadow-md'
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
All
@@ -142,15 +143,15 @@ export default function ShopPage() {
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
activeCategory === cat.id
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
: 'glass hover:bg-white/10'
? 'bg-spa-forest text-white border-spa-forest shadow-md'
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
}`}
>
{cat.name}
{cat._count && (
<span className="ml-2 opacity-60">({cat._count.products})</span>
<span className={`ml-2 ${activeCategory === cat.id ? 'opacity-80' : 'opacity-60'}`}>({cat._count.products})</span>
)}
</button>
))}
@@ -159,13 +160,13 @@ export default function ShopPage() {
{/* Results count */}
{(!loading) && (
<div className="flex items-center justify-between">
<p className="text-slate-500 text-sm">
<p className="text-spa-stone font-semibold text-sm">
{productsLoading ? 'Loading...' : `${totalProducts} products`}
</p>
{activeCategory && (
<Link
to={`/category/${activeCategory}`}
className="text-cyan-glow text-xs font-bold uppercase tracking-widest hover:underline"
className="text-spa-forest text-xs font-bold uppercase tracking-widest hover:underline"
>
View Category Page →
</Link>
@@ -177,15 +178,20 @@ export default function ShopPage() {
{loading || productsLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
<div key={i} className="rounded-2xl bg-spa-stone/10 animate-pulse h-96" />
))}
</div>
) : products.length === 0 ? (
<div className="text-center py-24 text-slate-500 space-y-3">
<p className="text-4xl">🌿</p>
<p className="text-lg">No products found</p>
<p className="text-sm">Try adjusting your search or filter</p>
</div>
<EmptyState
title="No products found"
message="Try adjusting your search or filter"
actionText="Clear Filters"
actionClick={() => {
setActiveCategory(null);
setSearchInput('');
setSearchQuery('');
}}
/>
) : (
<div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
@@ -197,7 +203,7 @@ export default function ShopPage() {
{/* Invisible target for IntersectionObserver */}
{displayCount < products.length && (
<div ref={loaderRef} className="py-12 flex justify-center">
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
<div className="w-8 h-8 rounded-full border-2 border-spa-forest border-t-transparent animate-spin" />
</div>
)}
</div>
+23 -23
View File
@@ -64,55 +64,55 @@ export default function TrackOrderPage() {
}
return (
<div className="min-h-screen flex flex-col">
<div className="min-h-screen flex flex-col bg-pop-bg">
<Navbar categories={categories} />
<main className="flex-1 max-w-4xl w-full mx-auto px-6 flex flex-col justify-center pt-32 pb-24 space-y-12 animate-fade-in">
{/* Header */}
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-cyan-glow/10 rounded-full flex items-center justify-center mx-auto border border-cyan-glow/20">
<MapPin className="w-8 h-8 text-cyan-glow" />
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mx-auto border border-spa-stone/10">
<MapPin className="w-8 h-8 text-spa-forest" />
</div>
<h1 className="text-4xl font-bold tracking-tight">Track Your Status</h1>
<p className="text-slate-400">Enter your order details below to check shipping status or upload payment slips.</p>
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Track Your Status</h1>
<p className="text-spa-stone">Enter your order details below to check shipping status or upload payment slips.</p>
</div>
<div className="grid md:grid-cols-2 gap-12">
{/* Tracking Form */}
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6 self-start">
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-white/10 pb-4 flex items-center gap-2 text-cyan-glow">
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 self-start shadow-sm">
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-spa-stone/10 pb-4 flex items-center gap-2 text-spa-forest">
<Search className="w-5 h-5" /> Manual Search
</h2>
<form onSubmit={handleTrackForm} className="space-y-5">
{error && (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-sm text-center">
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 font-medium rounded-lg text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Order Number (ID)</label>
<label className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Order Number (ID)</label>
<input
type="text"
required
value={orderId}
onChange={e => setOrderId(e.target.value)}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3.5 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all placeholder:text-slate-600"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3.5 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
placeholder="e.g. 1024"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Phone Number</label>
<label className="text-xs uppercase font-bold tracking-widest text-spa-stone/80">Phone Number</label>
<input
type="tel"
required
value={phone}
onChange={e => setPhone(e.target.value)}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3.5 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all placeholder:text-slate-600"
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3.5 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
placeholder="The phone number used during checkout"
/>
</div>
@@ -120,7 +120,7 @@ export default function TrackOrderPage() {
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_20px_rgba(102,252,241,0.3)] transition-all duration-500 disabled:opacity-50 flex items-center justify-center gap-2"
className="w-full py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300 disabled:opacity-50 flex items-center justify-center gap-2"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Find Order'}
</button>
@@ -129,14 +129,14 @@ export default function TrackOrderPage() {
{/* Local Order History */}
<div className="space-y-6">
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-white/10 pb-4 flex items-center gap-2 text-white">
<Package className="w-5 h-5 text-slate-400" /> Recent Device Orders
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-spa-stone/10 pb-4 flex items-center gap-2 text-pop-black">
<Package className="w-5 h-5 text-spa-stone/60" /> Recent Device Orders
</h2>
{localOrders.length === 0 ? (
<div className="text-center py-12 bg-white/5 rounded-2xl border border-dashed border-white/10 space-y-3">
<p className="text-slate-500 text-sm">No recent orders found on this device.</p>
<p className="text-[10px] uppercase text-slate-600 tracking-widest">Use manual search if you ordered elsewhere</p>
<div className="text-center py-12 bg-spa-light rounded-2xl border border-dashed border-spa-stone/20 space-y-3">
<p className="text-spa-stone text-sm font-semibold">No recent orders found on this device.</p>
<p className="text-[10px] uppercase text-spa-stone/60 tracking-widest font-bold">Use manual search if you ordered elsewhere</p>
</div>
) : (
<div className="space-y-3">
@@ -144,14 +144,14 @@ export default function TrackOrderPage() {
<Link
key={id}
to={`/order/${id}`}
className="flex justify-between items-center p-5 bg-white/5 rounded-xl border border-white/5 hover:bg-white/10 hover:border-cyan-glow/50 transition-all group"
className="flex justify-between items-center p-5 bg-white rounded-xl border border-spa-stone/10 shadow-sm hover:border-spa-forest transition-all group"
>
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-1">Order</p>
<p className="text-xl font-bold font-mono text-cyan-glow">#{id.toString().padStart(4, '0')}</p>
<p className="text-xs text-spa-stone/80 uppercase tracking-widest mb-1 font-semibold">Order</p>
<p className="text-xl font-bold font-mono text-spa-forest">#{id.toString().padStart(4, '0')}</p>
</div>
<div className="w-10 h-10 rounded-full bg-night-950 flex items-center justify-center group-hover:bg-cyan-glow transition-colors">
<ChevronRight className="w-5 h-5 text-slate-500 group-hover:text-night-900" />
<div className="w-10 h-10 rounded-full bg-spa-light flex items-center justify-center group-hover:bg-spa-forest transition-colors">
<ChevronRight className="w-5 h-5 text-spa-stone/80 group-hover:text-white" />
</div>
</Link>
))}
+13
View File
@@ -18,6 +18,19 @@ export default {
bg: "#FFF0F5",
black: "#333333",
},
night: {
900: "#111827",
},
cyan: {
glow: "#06b6d4",
},
spa: {
light: "#FDFDF9", // Off-white/Cream
sage: "#EAEFEA", // Soft Sage Green
stone: "#4B4B46", // Dark Stone Gray
forest: "#1F3D2D", // Deep Forest Green
gold: "#D4AF37", // Elegant Gold
}
},
boxShadow: {
'brutal': '0 10px 25px rgba(0,0,0,0.05)',
+1 -1
View File
@@ -7,7 +7,7 @@
# --- ตั้งค่าตรงนี้ ---
SERVER_USER="ti"
SERVER_HOST="192.168.1.167" # <-- เปลี่ยนเป็น IP หรือ Domain ของ server
SERVER_HOST="kitizone.com" # <-- เปลี่ยนเป็น IP หรือ Domain ของ server
REMOTE_HOOK_PATH="/home/ti/git/homaroma.git/hooks/post-receive"
# ---------------------