x
This commit is contained in:
1 parent
d325cd8763
commit
c6bf2f70ac
45 files changed
+5195
-338
No files matched your search
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: backend-mcp-lead
|
||||||
|
description: Backend & MCP Lead Agent รับผิดชอบวางระบบ API, จัดการเซิร์ฟเวอร์ และออกแบบ Model Context Protocol (MCP) Tools สำหรับ AI
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Backend & MCP Lead Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
คุณคือ **Backend & MCP Lead Agent** ประจำโปรเจกต์ HomAroma
|
||||||
|
ทำหน้าที่เขียนโค้ดฝั่ง Server-side (Node.js/Express) จัดการฐานข้อมูล (PostgreSQL/Prisma) ดูแล Infrastructure ของเซิร์ฟเวอร์ และที่สำคัญคือ **วางแผนและออกแบบ MCP Tools** เพื่อขยายขีดความสามารถให้ AI Assistants ตัวอื่นสามารถเข้ามาช่วยงานผ่าน API ได้อย่างปลอดภัยและมีประสิทธิภาพ
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
พัฒนา Backend API ให้เสถียรและรวดเร็ว ควบคู่กับการออกแบบระบบ MCP Tool Ecosystem เพื่อเพิ่มความสามารถ Automation สูงสุดให้กับโปรเจกต์
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Capabilities (ขีดความสามารถ)
|
||||||
|
- **API & Server Development:** พัฒนาและดูแลระบบ Backend ด้วย Node.js, Express (ห้ามพยายามรันคำสั่งจัดการเซิร์ฟเวอร์ด้วยตัวเอง)
|
||||||
|
- **Database Management:** ทำความเข้าใจและเขียนโค้ดอ่าน/เขียนข้อมูลผ่าน Prisma ORM ได้อย่างถูกต้องและมีประสิทธิภาพ
|
||||||
|
- **MCP Tool Architecture:** วิเคราะห์ความต้องการทางธุรกิจ (Business Gap) เพื่อออกแบบ Schema (JSON) สำหรับ MCP Tools ใหม่ๆ และเขียน Proxy ให้ AI เชื่อมต่อ
|
||||||
|
- **Security & Validation:** ออกแบบโครงสร้างที่ปลอดภัย ป้องกัน Injection และมีการ Validate ข้อมูลก่อนเข้า Database เสมอ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Constraints & Rules (กฎเหล็กในการทำงาน)
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **CRITICAL RULE: ห้ามรันคำสั่งกลุ่ม `prisma migrate` หรือ `prisma migrate reset` เด็ดขาด!**
|
||||||
|
> ไม่อนุญาตให้แก้ไข Database Schema หรือรันคำสั่งที่ส่งผลกระทบต่อโครงสร้างฐานข้อมูลโดยพลการ หากพบปัญหาหรือมีความจำเป็นต้องปรับปรุงโครงสร้าง **ให้หยุดทำงานและถาม Orchestrator หรือ User ทันที** เพื่อให้ทาง User เป็นคนพิจารณาและรันคำสั่งด้วยตนเอง
|
||||||
|
|
||||||
|
- **RULE #1: 12-Factor App Methodology**
|
||||||
|
การตั้งค่าต่างๆ (เช่น พอร์ต, URL, Database URI) ต้องดึงจาก Environment Variables (`.env`) เสมอ ห้าม Hardcode เด็ดขาด โดยเฉพาะการตั้งค่าเป้าหมายของ MCP Tools
|
||||||
|
- **RULE #2: No Destructive Commands**
|
||||||
|
ห้ามลบไฟล์ ดรอปตาราง หรือลบข้อมูลสำคัญในฐานข้อมูล (Production) โดยไม่ได้รับการยืนยันอย่างชัดเจน
|
||||||
|
- **RULE #3: สื่อสารเมื่อติดขัด**
|
||||||
|
หากพบปัญหาเรื่อง Backend รันไม่ขึ้น, เกิด Error ที่แก้ไขไม่ได้ทันที, หรือไม่แน่ใจผลลัพธ์ของคำสั่ง ให้รีบรายงานปัญหา (Escalate) กลับมาที่ User หรือ Orchestrator ทันที
|
||||||
|
- **RULE #4: ห้ามเดาคำสั่งจัดการเซิร์ฟเวอร์ (No Guessing)**
|
||||||
|
ห้ามพยายามรันคำสั่งจัดการ Process เช่น `pm2`, `docker`, หรือเดาวิธีการ Start/Restart เซิร์ฟเวอร์ขึ้นมาเอง หากต้องการทดสอบ ให้ใช้วิธี Build โค้ดเพื่อตรวจสอบ Syntax แทน หรือถ้าจำเป็นต้องเทสระบบจริงๆ **"ให้ถาม User หรือ Orchestrator ก่อนเสมอ"**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Task Workflow (ขั้นตอนการทำงาน)
|
||||||
|
1. **Analyze Request:** ทำความเข้าใจ Requirement ทั้งในมุม Backend ปกติ และในมุมของการทำเป็น MCP Tool
|
||||||
|
2. **Design & Plan:** ร่างโครงสร้างของ API Endpoint หรือ MCP JSON Schema หากกระทบฐานข้อมูล ให้แจ้ง User ทันที (ห้ามรัน migrate เอง)
|
||||||
|
3. **Develop API/Proxy:** เขียนโค้ดฝั่ง Express.js ควบคุม Logic ให้ปลอดภัยและมีประสิทธิภาพ
|
||||||
|
4. **Environment Setup:** นำตัวแปรที่เปลี่ยนแปลงไปใส่ใน `.env` และอัปเดตไฟล์ Documentation ถ้าจำเป็น
|
||||||
|
5. **Report & Handover:** รายงานผลการดำเนินงานให้ User รับทราบ พร้อมแนะนำวิธีการทดสอบระบบ
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: cms-architect
|
||||||
|
description: CMS & UI Builder Architect รับผิดชอบการออกแบบสถาปัตยกรรม Headless CMS, JSON Payload และระบบ Dynamic Page Builder
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: CMS & UI Builder Architect
|
||||||
|
|
||||||
|
## Role
|
||||||
|
คุณคือ **CMS & UI Builder Architect** ประจำโปรเจกต์ HomAroma
|
||||||
|
ทำหน้าที่รับผิดชอบสถาปัตยกรรมการแสดงผลหน้าเว็บแบบไดนามิก (Dynamic Page Builder) เปลี่ยนจากการ Hardcode Component เป็นการใช้ "Blocks/Sections" ที่ขับเคลื่อนด้วยข้อมูล JSON จาก Database เพื่อให้ฝ่ายการตลาดและแอดมินสามารถเรียงลำดับและปรับแต่งหน้าเว็บได้เอง
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
ออกแบบโครงสร้าง Database (เน้น JSON Payload) และสถาปัตยกรรม Frontend (Dynamic Renderer) ให้ระบบมีความยืดหยุ่นสูงสุด สามารถเพิ่ม ลบ และสลับ Section บนเว็บไซต์ได้อย่างอิสระ โดยยังคงประสิทธิภาพความเร็ว (Performance) ระดับสูง
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Capabilities (ขีดความสามารถ)
|
||||||
|
- **Data Structure Design:** ออกแบบ Database Schema สำหรับ Page และ PageSection รวมถึงวางโครงร่าง JSON Payload ให้ครอบคลุมทุก UI Block (เช่น Hero, Carousel, Banner)
|
||||||
|
- **Dynamic Renderer Architecture:** ออกแบบโครงสร้าง Component ใน Next.js/React ให้สามารถ Mapping ประเภทของ Block (`type`) ไปยัง UI Component ที่ถูกต้องได้แบบอัตโนมัติ
|
||||||
|
- **Admin Panel Planning:** วางโครงสร้างและ Flow การทำงานสำหรับหน้า Admin ให้รองรับการสร้างฟอร์ม (Dynamic Forms) สำหรับจัดการ JSON Payload โดยไม่ทำให้แอดมินสับสน
|
||||||
|
- **Performance Optimization:** วางแผนการทำ Caching และ Lazy Loading สำหรับ Block เพื่อป้องกันปัญหาหน้าเว็บโหลดช้าจากการคิวรี่โครงสร้างแบบไดนามิก
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Constraints & Rules (กฎเหล็กในการทำงาน)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **RULE #1: ห้ามสร้าง Block ขยะ (Strict Payload Validation)**
|
||||||
|
> การใช้ JSON Payload มีความเสี่ยงที่ข้อมูลจะผิดพลาด (Type Mismatch) ต้องออกแบบโครงสร้าง Types/Interfaces (Zod/TypeScript) ควบคู่กับ Schema เสมอ ห้ามปล่อยผ่าน Payload ที่ไม่มี Schema กั้น
|
||||||
|
|
||||||
|
- **RULE #2: ห้าม Hardcode Business Logic ใน Renderer**
|
||||||
|
Dynamic Renderer มีหน้าที่แค่รับ JSON และแสดงผล Component ห้ามใส่ Logic การคำนวณ หรือดึง API ซ้อนภายใน Renderer Component เด็ดขาด
|
||||||
|
- **RULE #3: สื่อสารกับทีมเสมอ**
|
||||||
|
เนื่องจากระบบนี้ต้องทำงานร่วมกับ Database (Backend) และ UI Components (Frontend) หากมีการเพิ่ม Block Type ใหม่ ต้องปรึกษาและวางแผนร่วมกับ `backend-mcp-lead` และ `frontend-lead` เสมอ ห้ามทำข้ามขอบเขตโดยพลการ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Task Workflow (ขั้นตอนการทำงาน)
|
||||||
|
1. **Analyze Requirements:** รวบรวมข้อมูลจาก User ว่าต้องการ Block แบบไหนบ้าง (เช่น สินค้าขายดี, บทความล่าสุด)
|
||||||
|
2. **Design JSON Schema:** ร่างโครงสร้าง JSON Payload ของแต่ละ Block และอธิบายวิธีการเก็บข้อมูลให้ชัดเจน
|
||||||
|
3. **Plan Frontend Renderer:** วางแผนว่าฝั่ง Frontend จะต้องสร้าง Component ไหนมารับ JSON Payload นั้น
|
||||||
|
4. **Coordinate Implementation:** ประสานงานกับ Backend เพื่อสร้าง API และ Frontend เพื่อสร้าง UI
|
||||||
|
5. **Review & Audit:** ตรวจสอบความถูกต้องของการเรนเดอร์ และ Performance ของหน้าเว็บ
|
||||||
@@ -6,6 +6,7 @@ import ProductEditPage from './pages/ProductEditPage'
|
|||||||
import CategoriesPage from './pages/CategoriesPage'
|
import CategoriesPage from './pages/CategoriesPage'
|
||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
import HeroSettingsPage from './pages/HeroSettingsPage'
|
import HeroSettingsPage from './pages/HeroSettingsPage'
|
||||||
|
import PageBuilderPage from './pages/PageBuilderPage'
|
||||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import ProtectedRoute from './components/ProtectedRoute'
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
@@ -46,6 +47,7 @@ function App() {
|
|||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/hero" element={<HeroSettingsPage />} />
|
<Route path="/hero" element={<HeroSettingsPage />} />
|
||||||
|
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
<Route path="/promotions" element={<PromotionsPage />} />
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Image,
|
|
||||||
Tags,
|
Tags,
|
||||||
Ticket,
|
Ticket,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -28,7 +27,7 @@ const MENU_ITEMS = [
|
|||||||
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
|
||||||
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
|
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
|
||||||
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
||||||
{ icon: Image, label: 'Hero Section', to: '/hero' },
|
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
|
||||||
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
|
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
|
||||||
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
|
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
|
||||||
{ icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' },
|
{ icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' },
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Order" ADD COLUMN "pointsDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "pointsEarned" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "pointsUsed" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "promoLevel1Disc" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "promoLevel1Name" TEXT,
|
||||||
|
ADD COLUMN "promoLevel2Disc" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "promoLevel2Name" TEXT,
|
||||||
|
ADD COLUMN "subtotal" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "trackingNo" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "StoreSettings" ADD COLUMN "pointsEarnRate" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||||
|
ALTER COLUMN "bankAccountName" SET DEFAULT 'HomAroma Co., Ltd.';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Promotion" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" DOUBLE PRECISION NOT NULL,
|
||||||
|
"minPurchase" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"startDate" TIMESTAMP(3),
|
||||||
|
"endDate" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Review" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"rating" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"comment" TEXT,
|
||||||
|
"productId" INTEGER NOT NULL,
|
||||||
|
"customerId" INTEGER,
|
||||||
|
"customerName" TEXT,
|
||||||
|
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_productId_idx" ON "Review"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_customerId_idx" ON "Review"("customerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Review" ADD CONSTRAINT "Review_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Page" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PageSection" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"pageId" INTEGER NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PageSection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PageSection" ADD CONSTRAINT "PageSection_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NewsletterSubscription" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "NewsletterSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "NewsletterSubscription_email_key" ON "NewsletterSubscription"("email");
|
||||||
@@ -165,6 +165,7 @@ model Order {
|
|||||||
totalAmount Float
|
totalAmount Float
|
||||||
status String @default("PENDING_PAYMENT")
|
status String @default("PENDING_PAYMENT")
|
||||||
paymentSlipUrl String?
|
paymentSlipUrl String?
|
||||||
|
trackingNo String?
|
||||||
|
|
||||||
customerId Int?
|
customerId Int?
|
||||||
customer Customer? @relation(fields: [customerId], references: [id])
|
customer Customer? @relation(fields: [customerId], references: [id])
|
||||||
@@ -420,3 +421,33 @@ model Review {
|
|||||||
@@index([productId])
|
@@index([productId])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Page {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
slug String @unique // e.g., "home", "about-us"
|
||||||
|
title String
|
||||||
|
sections PageSection[]
|
||||||
|
isPublished Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model PageSection {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
pageId Int
|
||||||
|
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||||
|
type String // e.g., "HERO", "CATEGORIES", "PRODUCTS", "BANNER", "TEXT"
|
||||||
|
order Int // Display order
|
||||||
|
payload Json // Dynamic JSON payload
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model NewsletterSubscription {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🌱 Seeding Dynamic Page Builder...");
|
||||||
|
|
||||||
|
// 1. Create the HomePage
|
||||||
|
const page = await prisma.page.upsert({
|
||||||
|
where: { slug: "home" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "home",
|
||||||
|
title: "Home Page",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${page.title} (slug: ${page.slug})`);
|
||||||
|
|
||||||
|
// Clear existing sections for home page so we don't duplicate
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: page.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create default sections: HERO, CATEGORIES, PRODUCTS, BANNER, TEXT
|
||||||
|
|
||||||
|
// Section 1: Hero
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: page.id,
|
||||||
|
type: "HERO",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
tagline: "VIBRANT ELEGANCE COLLECTION",
|
||||||
|
title: "Inhale [Serenity]",
|
||||||
|
description: "Crafting premium sensory experiences that transform your space into a sanctuary of peace.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
buttonText: "Explore Collection",
|
||||||
|
buttonLink: "/shop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section 2: Categories
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: page.id,
|
||||||
|
type: "CATEGORIES",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Our Collections",
|
||||||
|
subtitle: "Discover the perfect scent for every moment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section 3: Products (Featured Picks)
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: page.id,
|
||||||
|
type: "PRODUCTS",
|
||||||
|
order: 3,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Featured Picks",
|
||||||
|
subtitle: "Hand-picked fragrances recommended just for you",
|
||||||
|
type: "featured",
|
||||||
|
limit: 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section 4: Banner
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: page.id,
|
||||||
|
type: "BANNER",
|
||||||
|
order: 4,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
imageUrl: "/images/oil_ozone.png",
|
||||||
|
linkUrl: "/category/1",
|
||||||
|
title: "Special Offer",
|
||||||
|
description: "Get 10% off on your first purchase of Oil Ozone collection",
|
||||||
|
buttonText: "Shop Now"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section 5: Text info
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: page.id,
|
||||||
|
type: "TEXT",
|
||||||
|
order: 5,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "About HomAroma",
|
||||||
|
content: "HomAroma is dedicated to sourcing the finest raw materials to formulate high-quality essential oils, reed diffusers, and room sprays. Our goal is to bring nature's purity directly into your life.",
|
||||||
|
textAlign: "center"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Seeded 5 default home page sections successfully!");
|
||||||
|
|
||||||
|
// 3. Create the Shop Page Layout
|
||||||
|
const shopPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "shop-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "shop-layout",
|
||||||
|
title: "Shop Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${shopPage.title} (slug: ${shopPage.slug})`);
|
||||||
|
|
||||||
|
// Clear existing sections for shop page so we don't duplicate
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: shopPage.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shop Section 1: Announcement Bar
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: shopPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["[FREE SHIPPING] on orders over ฿1,500", "New Premium Collection is out now!"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shop Section 2: Hero Banner (Set to active by default to showcase the header capabilities)
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: shopPage.id,
|
||||||
|
type: "HERO",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
tagline: "CURATED BOTANICALS",
|
||||||
|
title: "The [Shop] Collection",
|
||||||
|
description: "Transform your daily space into a luxury escape with our natural aroma range.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
buttonText: "Learn More",
|
||||||
|
buttonLink: "/about"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shop Section 3: Editorial Tile
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: shopPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 3,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Crafted with [Sensory Passion]",
|
||||||
|
description: "Every formulation at HomAroma is a tribute to pure botanical essences, hand-blended to capture nature’s quiet intelligence.",
|
||||||
|
imageUrl: "/images/oil_ozone.png",
|
||||||
|
imagePosition: "right",
|
||||||
|
buttonText: "Read Our Story",
|
||||||
|
buttonLink: "/about",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shop Section 4: Newsletter
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: shopPage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 4,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Begin Your Wellness Journey",
|
||||||
|
description: "Subscribe to receive 10% off your first purchase and exclusive updates.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Seeded default shop-layout page sections successfully!");
|
||||||
|
|
||||||
|
// 4. Create the Scent Guide Page Layout
|
||||||
|
const scentGuidePage = await prisma.page.upsert({
|
||||||
|
where: { slug: "scent-guide-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "scent-guide-layout",
|
||||||
|
title: "Scent Guide Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${scentGuidePage.title} (slug: ${scentGuidePage.slug})`);
|
||||||
|
|
||||||
|
// Clear existing sections for scent guide page so we don't duplicate
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: scentGuidePage.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scent Guide Section 1: Announcement Bar
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: scentGuidePage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Explore our Scent Guide to find your signature fragrance", "Read wellness articles crafted by experts"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scent Guide Section 2: Hero Banner
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: scentGuidePage.id,
|
||||||
|
type: "HERO",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
tagline: "JOURNAL & INSIGHTS",
|
||||||
|
title: "The [Scent] Guide",
|
||||||
|
description: "Welcome to the sensory archive of HomAroma. Explore the art and science of botanical aromatherapy.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
buttonText: "Read Story",
|
||||||
|
buttonLink: "/about"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scent Guide Section 3: Newsletter
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: scentGuidePage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 3,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Stay Inspired",
|
||||||
|
description: "Subscribe to our journal and get regular wellness guides, blending tips, and exclusive offers.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Seeded default scent-guide-layout page sections successfully!");
|
||||||
|
|
||||||
|
// 5. Create Category Page Layout
|
||||||
|
const categoryPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "category-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "category-layout",
|
||||||
|
title: "Category Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${categoryPage.title} (slug: ${categoryPage.slug})`);
|
||||||
|
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: categoryPage.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcement Bar for Categories
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: categoryPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Explore our specialized collections for your sanctuary", "Free premium shipping on orders over ฿1,500"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Newsletter at the bottom of Categories
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: categoryPage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Join [HomAroma] Circle",
|
||||||
|
description: "Subscribe to receive announcements of new single-origin botanical releases.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Create Product Detail Page Layout
|
||||||
|
const productDetailPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "product-detail-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "product-detail-layout",
|
||||||
|
title: "Product Detail Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${productDetailPage.title} (slug: ${productDetailPage.slug})`);
|
||||||
|
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: productDetailPage.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcement Bar for Product Details
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: productDetailPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editorial banner at the bottom of Product Details
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: productDetailPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "The [Botanical] Sourcing",
|
||||||
|
description: "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.",
|
||||||
|
imageUrl: "/images/oil_ozone.png",
|
||||||
|
imagePosition: "left",
|
||||||
|
buttonText: "Sourcing Map",
|
||||||
|
buttonLink: "/about",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Create FAQ Page Layout
|
||||||
|
const faqPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "faq-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "faq-layout",
|
||||||
|
title: "FAQ Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Created page: ${faqPage.title} (slug: ${faqPage.slug})`);
|
||||||
|
|
||||||
|
await prisma.pageSection.deleteMany({
|
||||||
|
where: { pageId: faqPage.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcement Bar for FAQ
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: faqPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Need instant help? Reach out on LINE @homaroma", "FAQ updated for Summer 2026 delivery guidelines"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 4000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Newsletter at the bottom of FAQ
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: faqPage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Stay informed with [HomAroma]",
|
||||||
|
description: "Sign up to receive shipping updates, care guides, and exclusive collection invites.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Seeded default category, product-detail, and FAQ layout sections successfully!");
|
||||||
|
|
||||||
|
// 8. Create Wishlist Page Layout
|
||||||
|
const wishlistPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "wishlist-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "wishlist-layout",
|
||||||
|
title: "Wishlist Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${wishlistPage.title} (slug: ${wishlistPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: wishlistPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: wishlistPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Sign up to HomAroma Circle to get 10% off your wishlist items", "Hand-crafted botanicals shipped directly to you"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: wishlistPage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Don't lose your favorites",
|
||||||
|
description: "Subscribe to receive price drops, back-in-stock alerts, and exclusive discounts on items in your wishlist.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. Create Search Page Layout
|
||||||
|
const searchPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "search-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "search-layout",
|
||||||
|
title: "Search Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${searchPage.title} (slug: ${searchPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: searchPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: searchPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Need help finding something? Live Chat with our scent experts on LINE @homaroma"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: searchPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Find Your [Signature Scent]",
|
||||||
|
description: "Aromatherapy is the art of tuning your environment. Discover our curation of room sprays, reed diffusers, and essential oils designed for modern sanctuaries.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
imagePosition: "right",
|
||||||
|
buttonText: "Explore Guide",
|
||||||
|
buttonLink: "/scent-guide",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. Create Coupons Page Layout
|
||||||
|
const couponsPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "coupons-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "coupons-layout",
|
||||||
|
title: "Coupons Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${couponsPage.title} (slug: ${couponsPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: couponsPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: couponsPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Enjoy limited-time discount codes for your sensory sanctuary"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: couponsPage.id,
|
||||||
|
type: "NEWSLETTER",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Get Exclusive Secret Codes",
|
||||||
|
description: "Become a subscriber to get weekly private discount codes and early access to new collections.",
|
||||||
|
buttonText: "Subscribe",
|
||||||
|
placeholderText: "Enter your email address",
|
||||||
|
showPrivacyCheckbox: true,
|
||||||
|
privacyText: "I agree to the privacy policy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 11. Create Contact Page Layout
|
||||||
|
const contactPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "contact-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "contact-layout",
|
||||||
|
title: "Contact Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${contactPage.title} (slug: ${contactPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: contactPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: contactPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Customer support is available Mon-Sat, 9:00 - 18:00 on LINE @homaroma"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 4000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: contactPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Our [Bangkok Studio]",
|
||||||
|
description: "Visit our flagship boutique to experience custom blending sessions and smell our full botanical library in person.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
imagePosition: "left",
|
||||||
|
buttonText: "Book Session",
|
||||||
|
buttonLink: "/about",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 12. Create Order Success Page Layout
|
||||||
|
const orderSuccessPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "order-success-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "order-success-layout",
|
||||||
|
title: "Order Success Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${orderSuccessPage.title} (slug: ${orderSuccessPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: orderSuccessPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: orderSuccessPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Thank you for shopping with HomAroma! Your package is preparing.", "Your order tracking status will be updated here."],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: orderSuccessPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Crafting Your [Sanctuary]",
|
||||||
|
description: "Every order is carefully packaged by hand using sustainable, biodegradable materials. We ensure your botanicals arrive fresh and ready to enrich your space.",
|
||||||
|
imageUrl: "/images/oil_ozone.png",
|
||||||
|
imagePosition: "right",
|
||||||
|
buttonText: "Our Sustainability Commitments",
|
||||||
|
buttonLink: "/about",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 13. Create Track Order Page Layout
|
||||||
|
const trackOrderPage = await prisma.page.upsert({
|
||||||
|
where: { slug: "track-order-layout" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "track-order-layout",
|
||||||
|
title: "Track Order Page Layout",
|
||||||
|
isPublished: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Created page: ${trackOrderPage.title} (slug: ${trackOrderPage.slug})`);
|
||||||
|
await prisma.pageSection.deleteMany({ where: { pageId: trackOrderPage.id } });
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: trackOrderPage.id,
|
||||||
|
type: "ANNOUNCEMENT_BAR",
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
slides: ["Check shipping status live. For any delivery queries, reach LINE @homaroma"],
|
||||||
|
backgroundColor: "#1F3D2D",
|
||||||
|
textColor: "#FDFDF9",
|
||||||
|
speed: 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId: trackOrderPage.id,
|
||||||
|
type: "EDITORIAL_TILE",
|
||||||
|
order: 2,
|
||||||
|
isActive: true,
|
||||||
|
payload: {
|
||||||
|
title: "Scenting Your [Daily Rituals]",
|
||||||
|
description: "While waiting for your order, explore how different botanical notes impact focus, sleep, and emotional balance in our Scent Guide.",
|
||||||
|
imageUrl: "/images/hero.png",
|
||||||
|
imagePosition: "left",
|
||||||
|
buttonText: "Read Scent Guide",
|
||||||
|
buttonLink: "/scent-guide",
|
||||||
|
backgroundColor: "#FDFDF9",
|
||||||
|
textColor: "#1F3D2D"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Seeded default Category, Product Detail, FAQ, Wishlist, Search, Coupons, Contact, Order Success, and Track Order layouts successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("❌ Error seeding page:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { customersRoute } from "./routes/customers";
|
|||||||
import { couponsRoute } from "./routes/coupons";
|
import { couponsRoute } from "./routes/coupons";
|
||||||
import { reviewsRoute } from "./routes/reviews";
|
import { reviewsRoute } from "./routes/reviews";
|
||||||
import { promotionsRoute } from "./routes/promotions";
|
import { promotionsRoute } from "./routes/promotions";
|
||||||
|
import { pagesRoute } from "./routes/pages";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||||
@@ -83,7 +84,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute)
|
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute)
|
||||||
)
|
)
|
||||||
.listen(PORT);
|
.listen(PORT);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
export const customersRoute = new Elysia({ prefix: "/customers" })
|
export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||||
.get("/", async () => {
|
.get("/", async ({ query }) => {
|
||||||
|
const search = query.search as string | undefined;
|
||||||
|
|
||||||
|
const whereClause = search ? {
|
||||||
|
OR: [
|
||||||
|
{ displayName: { contains: search, mode: "insensitive" as any } },
|
||||||
|
{ email: { contains: search, mode: "insensitive" as any } },
|
||||||
|
{ phone: { contains: search, mode: "insensitive" as any } },
|
||||||
|
{ lineUserId: { contains: search, mode: "insensitive" as any } }
|
||||||
|
]
|
||||||
|
} : {};
|
||||||
|
|
||||||
return await prisma.customer.findMany({
|
return await prisma.customer.findMany({
|
||||||
|
where: whereClause,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -22,4 +34,36 @@ export const customersRoute = new Elysia({ prefix: "/customers" })
|
|||||||
return { error: "Customer not found" };
|
return { error: "Customer not found" };
|
||||||
}
|
}
|
||||||
return customer;
|
return customer;
|
||||||
|
})
|
||||||
|
.patch("/:id/points", async ({ params: { id }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const { pointsDelta, reason } = body as { pointsDelta: number, reason?: string };
|
||||||
|
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: Number(id) }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Customer not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPoints = Math.max(0, customer.points + pointsDelta);
|
||||||
|
|
||||||
|
const updatedCustomer = await prisma.customer.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { points: newPoints }
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedCustomer;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Customers] Adjust Points Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to adjust points" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
pointsDelta: t.Number(),
|
||||||
|
reason: t.Optional(t.String())
|
||||||
|
})
|
||||||
});
|
});
|
||||||
@@ -176,6 +176,23 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
|
|||||||
paymentSlipUrl: t.String()
|
paymentSlipUrl: t.String()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const { trackingNo } = body as any;
|
||||||
|
return await prisma.order.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { trackingNo },
|
||||||
|
include: { items: true }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to update tracking number" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
trackingNo: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
.get("/customer/:customerId", async ({ params: { customerId } }) => {
|
.get("/customer/:customerId", async ({ params: { customerId } }) => {
|
||||||
return await prisma.order.findMany({
|
return await prisma.order.findMany({
|
||||||
where: { customerId: Number(customerId) },
|
where: { customerId: Number(customerId) },
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
|
.get("/", async () => {
|
||||||
|
return await prisma.page.findMany({
|
||||||
|
include: {
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.get("/:slug", async ({ params: { slug }, set }) => {
|
||||||
|
const page = await prisma.page.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
sections: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!page) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Page not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const populatedSections = await Promise.all(
|
||||||
|
page.sections.map(async (section) => {
|
||||||
|
const payload = section.payload as any;
|
||||||
|
if (!payload) return section;
|
||||||
|
|
||||||
|
const populatedPayload = { ...payload };
|
||||||
|
|
||||||
|
if (section.type === 'PRODUCTS') {
|
||||||
|
let products: any[] = [];
|
||||||
|
const limit = payload.limit || 3;
|
||||||
|
|
||||||
|
const productInclude = {
|
||||||
|
category: true,
|
||||||
|
scent: true,
|
||||||
|
variants: {
|
||||||
|
include: {
|
||||||
|
bomItems: { include: { rawMaterial: true } }
|
||||||
|
},
|
||||||
|
orderBy: { price: "asc" as const }
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
orderBy: { order: "asc" as const }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.type === 'featured') {
|
||||||
|
products = await prisma.product.findMany({
|
||||||
|
where: { categoryId: 1 },
|
||||||
|
include: productInclude,
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
} else if (payload.type === 'scent' && payload.scentId) {
|
||||||
|
products = await prisma.product.findMany({
|
||||||
|
where: { scentId: Number(payload.scentId) },
|
||||||
|
include: productInclude,
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
} else if (payload.type === 'custom' && Array.isArray(payload.productIds)) {
|
||||||
|
const ids = payload.productIds.map(Number);
|
||||||
|
products = await prisma.product.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: productInclude
|
||||||
|
});
|
||||||
|
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
populatedPayload.products = products;
|
||||||
|
} else if (section.type === 'CATEGORIES') {
|
||||||
|
let categories: any[] = [];
|
||||||
|
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||||
|
const ids = payload.categoryIds.map(Number);
|
||||||
|
categories = await prisma.category.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { products: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||||
|
} else {
|
||||||
|
categories = await prisma.category.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { products: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
populatedPayload.categories = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
payload: populatedPayload
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
sections: populatedSections
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.post("/", async ({ body, set }) => {
|
||||||
|
try {
|
||||||
|
const data = body as any;
|
||||||
|
const { id, slug, title, isPublished } = data;
|
||||||
|
if (id) {
|
||||||
|
return await prisma.page.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { slug, title, isPublished: isPublished !== false }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return await prisma.page.create({
|
||||||
|
data: { slug, title, isPublished: isPublished !== false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Pages] Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to save page" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Optional(t.Number()),
|
||||||
|
slug: t.String(),
|
||||||
|
title: t.String(),
|
||||||
|
isPublished: t.Optional(t.Boolean())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.delete("/:slug", async ({ params: { slug }, set }) => {
|
||||||
|
try {
|
||||||
|
await prisma.page.delete({
|
||||||
|
where: { id: Number(slug) }
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to delete page" };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get("/:slug/sections", async ({ params: { slug }, set }) => {
|
||||||
|
try {
|
||||||
|
return await prisma.pageSection.findMany({
|
||||||
|
where: { pageId: Number(slug) },
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to fetch page sections" };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post("/:slug/sections", async ({ params: { slug }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const pageId = Number(slug);
|
||||||
|
const { type, order, payload, isActive } = body as any;
|
||||||
|
|
||||||
|
const validationError = validateSectionPayload(type, payload);
|
||||||
|
if (validationError) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: validationError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.pageSection.create({
|
||||||
|
data: {
|
||||||
|
pageId,
|
||||||
|
type: type.toUpperCase(),
|
||||||
|
order,
|
||||||
|
payload,
|
||||||
|
isActive: isActive !== false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PageSections] Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to add section" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
type: t.String(),
|
||||||
|
order: t.Number(),
|
||||||
|
payload: t.Any(),
|
||||||
|
isActive: t.Optional(t.Boolean())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.put("/sections/:sectionId", async ({ params: { sectionId }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const id = Number(sectionId);
|
||||||
|
const { type, order, payload, isActive } = body as any;
|
||||||
|
|
||||||
|
if (payload && type) {
|
||||||
|
const validationError = validateSectionPayload(type, payload);
|
||||||
|
if (validationError) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: validationError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.pageSection.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
type: type ? type.toUpperCase() : undefined,
|
||||||
|
order,
|
||||||
|
payload,
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PageSections] Update Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to update section" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
type: t.Optional(t.String()),
|
||||||
|
order: t.Optional(t.Number()),
|
||||||
|
payload: t.Optional(t.Any()),
|
||||||
|
isActive: t.Optional(t.Boolean())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.put("/:slug/sections/reorder", async ({ params: { slug }, body, set }) => {
|
||||||
|
try {
|
||||||
|
const pageId = Number(slug);
|
||||||
|
const { sectionOrders } = body as { sectionOrders: { id: number; order: number }[] };
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
sectionOrders.map((item) =>
|
||||||
|
prisma.pageSection.update({
|
||||||
|
where: { id: item.id, pageId },
|
||||||
|
data: { order: item.order }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PageSections] Reorder Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to reorder sections" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
sectionOrders: t.Array(t.Object({
|
||||||
|
id: t.Number(),
|
||||||
|
order: t.Number()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post("/newsletter", async ({ body, set }) => {
|
||||||
|
try {
|
||||||
|
const { email } = body as { email: string };
|
||||||
|
if (!email || !email.includes("@")) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: "Invalid email address" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await prisma.newsletterSubscription.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: {},
|
||||||
|
create: { email }
|
||||||
|
});
|
||||||
|
return { success: true, data: sub };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Newsletter] Error:", err);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to subscribe" };
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
email: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => {
|
||||||
|
try {
|
||||||
|
await prisma.pageSection.delete({
|
||||||
|
where: { id: Number(sectionId) }
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to delete section" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateSectionPayload(type: string, payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return "Payload must be a JSON object";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type.toUpperCase()) {
|
||||||
|
case 'HERO':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Hero payload must contain a non-empty string title";
|
||||||
|
if (typeof payload.description !== 'string') return "Hero payload must contain a string description";
|
||||||
|
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Hero payload must contain a non-empty string imageUrl";
|
||||||
|
if (typeof payload.tagline !== 'string') return "Hero payload must contain a string tagline";
|
||||||
|
if (typeof payload.buttonText !== 'string') return "Hero payload must contain a string buttonText";
|
||||||
|
if (typeof payload.buttonLink !== 'string') return "Hero payload must contain a string buttonLink";
|
||||||
|
break;
|
||||||
|
case 'CATEGORIES':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Categories payload must contain a non-empty string title";
|
||||||
|
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
|
||||||
|
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
|
||||||
|
return "categoryIds must be an array of numbers";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'PRODUCTS':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Products payload must contain a non-empty string title";
|
||||||
|
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
|
||||||
|
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
|
||||||
|
return "Products type must be 'featured', 'scent', or 'custom'";
|
||||||
|
}
|
||||||
|
if (payload.type === 'scent' && typeof payload.scentId !== 'number') {
|
||||||
|
return "Scent products section requires a numeric scentId";
|
||||||
|
}
|
||||||
|
if (payload.type === 'custom' && !Array.isArray(payload.productIds)) {
|
||||||
|
return "Custom products section requires an array of productIds";
|
||||||
|
}
|
||||||
|
if (payload.limit !== undefined && typeof payload.limit !== 'number') {
|
||||||
|
return "Limit must be a number";
|
||||||
|
}
|
||||||
|
if (payload.layout !== undefined && !['grid', 'carousel'].includes(payload.layout)) {
|
||||||
|
return "layout must be 'grid' or 'carousel'";
|
||||||
|
}
|
||||||
|
if (payload.enableQuickShop !== undefined && typeof payload.enableQuickShop !== 'boolean') {
|
||||||
|
return "enableQuickShop must be a boolean";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'BANNER':
|
||||||
|
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Banner payload must contain a non-empty string imageUrl";
|
||||||
|
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string') return "linkUrl must be a string";
|
||||||
|
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
|
||||||
|
if (payload.description !== undefined && typeof payload.description !== 'string') return "description must be a string";
|
||||||
|
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string') return "buttonText must be a string";
|
||||||
|
break;
|
||||||
|
case 'TEXT':
|
||||||
|
if (typeof payload.content !== 'string' || !payload.content.trim()) return "Text payload must contain a non-empty string content";
|
||||||
|
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
|
||||||
|
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
|
||||||
|
return "textAlign must be 'left', 'center', or 'right'";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ANNOUNCEMENT_BAR':
|
||||||
|
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Announcement Bar requires an array of slides";
|
||||||
|
if (payload.slides.some((s: any) => typeof s !== 'string')) return "All slides must be strings";
|
||||||
|
break;
|
||||||
|
case 'NEWSLETTER':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Newsletter payload must contain a non-empty string title";
|
||||||
|
if (typeof payload.description !== 'string') return "Newsletter description must be a string";
|
||||||
|
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim()) return "Newsletter buttonText must be a non-empty string";
|
||||||
|
if (typeof payload.showPrivacyCheckbox !== 'boolean') return "showPrivacyCheckbox must be a boolean";
|
||||||
|
break;
|
||||||
|
case 'EDITORIAL_TILE':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Tile payload must contain a non-empty string title";
|
||||||
|
if (typeof payload.description !== 'string') return "Editorial Tile description must be a string";
|
||||||
|
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Editorial Tile imageUrl must be a non-empty string";
|
||||||
|
if (!['left', 'right'].includes(payload.imagePosition)) return "imagePosition must be 'left' or 'right'";
|
||||||
|
break;
|
||||||
|
case 'EDITORIAL_CAROUSEL':
|
||||||
|
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Carousel payload must contain a non-empty string title";
|
||||||
|
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Editorial Carousel requires an array of slides";
|
||||||
|
for (const [idx, slide] of payload.slides.entries()) {
|
||||||
|
if (typeof slide.title !== 'string' || !slide.title.trim()) return `Slide #${idx+1} must contain a non-empty title`;
|
||||||
|
if (typeof slide.description !== 'string') return `Slide #${idx+1} must contain a description string`;
|
||||||
|
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim()) return `Slide #${idx+1} must contain a non-empty imageUrl`;
|
||||||
|
if (typeof slide.category !== 'string') return `Slide #${idx+1} must contain a category string`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return `Unknown section type: ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { Elysia, t } from "elysia";
|
|||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||||
|
.get("/scents", async () => {
|
||||||
|
return await prisma.scent.findMany({
|
||||||
|
orderBy: { name: "asc" }
|
||||||
|
});
|
||||||
|
})
|
||||||
.get("/", async ({ query }) => {
|
.get("/", async ({ query }) => {
|
||||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||||
|
|||||||
@@ -124,6 +124,140 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
required: ["productId", "rating", "customerName"]
|
required: ["productId", "rating", "customerName"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_order_status",
|
||||||
|
description: "Get the status and details of a specific order",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
orderId: { type: "number" }
|
||||||
|
},
|
||||||
|
required: ["orderId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_pending_orders",
|
||||||
|
description: "List orders that are pending payment or processing",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
limit: { type: "number", description: "Max results" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_order_tracking",
|
||||||
|
description: "Update the tracking number of an order and optionally its status",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
orderId: { type: "number" },
|
||||||
|
trackingNo: { type: "string" },
|
||||||
|
status: { type: "string", description: "Optional status to update (e.g. SHIPPED)" }
|
||||||
|
},
|
||||||
|
required: ["orderId", "trackingNo"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search_customer",
|
||||||
|
description: "Search for customers by name, email, phone, or line ID",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: { type: "string", description: "Search term" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adjust_customer_points",
|
||||||
|
description: "Add or deduct points for a customer",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Customer ID" },
|
||||||
|
pointsDelta: { type: "number", description: "Amount of points to add (positive) or deduct (negative)" },
|
||||||
|
reason: { type: "string", description: "Reason for the adjustment" }
|
||||||
|
},
|
||||||
|
required: ["id", "pointsDelta"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generate_special_coupon",
|
||||||
|
description: "Generate a special discount coupon",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
code: { type: "string", description: "Coupon code (e.g. VIP20)" },
|
||||||
|
type: { type: "string", description: "PERCENTAGE, FIXED_AMOUNT, or FREE_SHIPPING" },
|
||||||
|
value: { type: "number", description: "Discount value" },
|
||||||
|
minPurchase: { type: "number", description: "Minimum purchase amount" },
|
||||||
|
maxDiscount: { type: "number", description: "Maximum discount amount for PERCENTAGE type" },
|
||||||
|
usageLimit: { type: "number", description: "Maximum number of times this coupon can be used" },
|
||||||
|
expiresAt: { type: "string", description: "ISO date string for expiration" }
|
||||||
|
},
|
||||||
|
required: ["code", "type", "value"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_dynamic_pages",
|
||||||
|
description: "List all dynamic pages and layouts managed by the CMS",
|
||||||
|
inputSchema: { type: "object", properties: {} }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create_dynamic_page",
|
||||||
|
description: "Create or update a dynamic page or layout",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string", description: "Title of the page" },
|
||||||
|
slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" },
|
||||||
|
isPublished: { type: "boolean", description: "Publish status" },
|
||||||
|
id: { type: "number", description: "Numeric page ID (if updating existing page)" }
|
||||||
|
},
|
||||||
|
required: ["title", "slug"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_page_section",
|
||||||
|
description: "Add a block section to a dynamic page layout",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pageId: { type: "number", description: "The numeric ID of the page" },
|
||||||
|
type: { type: "string", description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER)" },
|
||||||
|
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
||||||
|
payload: { type: "object", description: "The JSON payload matching the section type rules" },
|
||||||
|
isActive: { type: "boolean", description: "Initial active state" }
|
||||||
|
},
|
||||||
|
required: ["pageId", "type", "order", "payload"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_page_section",
|
||||||
|
description: "Update an existing block section payload, order or active state",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
type: { type: "string", description: "Optional new block type" },
|
||||||
|
order: { type: "number", description: "Optional new display order sequence" },
|
||||||
|
payload: { type: "object", description: "Optional new JSON payload matching the section type rules" },
|
||||||
|
isActive: { type: "boolean", description: "Optional new active state" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_page_section",
|
||||||
|
description: "Delete a block section from a dynamic page layout",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -198,6 +332,91 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
});
|
});
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "get_order_status": {
|
||||||
|
const res = await api.get(`/orders/${params.orderId}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "list_pending_orders": {
|
||||||
|
const res = await api.get(`/orders`);
|
||||||
|
const pending = res.data.filter((o: any) => o.status === "PENDING_PAYMENT" || o.status === "PENDING" || o.status === "PROCESSING");
|
||||||
|
if (params.limit) {
|
||||||
|
pending.splice(params.limit);
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(pending, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_order_tracking": {
|
||||||
|
const payload: any = { trackingNo: params.trackingNo };
|
||||||
|
const res1 = await api.patch(`/orders/${params.orderId}/tracking`, payload);
|
||||||
|
|
||||||
|
let result = res1.data;
|
||||||
|
if (params.status) {
|
||||||
|
const res2 = await api.patch(`/orders/${params.orderId}/status`, { status: params.status });
|
||||||
|
result = res2.data;
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "search_customer": {
|
||||||
|
const search = params.search ? `?search=${encodeURIComponent(params.search)}` : "";
|
||||||
|
const res = await api.get(`/customers${search}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "adjust_customer_points": {
|
||||||
|
const payload = { pointsDelta: params.pointsDelta, reason: params.reason };
|
||||||
|
const res = await api.patch(`/customers/${params.id}/points`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "generate_special_coupon": {
|
||||||
|
const payload = {
|
||||||
|
code: params.code,
|
||||||
|
type: params.type,
|
||||||
|
value: params.value,
|
||||||
|
minPurchase: params.minPurchase,
|
||||||
|
maxDiscount: params.maxDiscount,
|
||||||
|
usageLimit: params.usageLimit,
|
||||||
|
expiresAt: params.expiresAt
|
||||||
|
};
|
||||||
|
const res = await api.post(`/coupons`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "list_dynamic_pages": {
|
||||||
|
const res = await api.get("/pages");
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "create_dynamic_page": {
|
||||||
|
const payload: any = {
|
||||||
|
title: params.title,
|
||||||
|
slug: params.slug,
|
||||||
|
isPublished: params.isPublished !== false
|
||||||
|
};
|
||||||
|
if (params.id) {
|
||||||
|
payload.id = params.id;
|
||||||
|
}
|
||||||
|
const res = await api.post("/pages", payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "add_page_section": {
|
||||||
|
const payload = {
|
||||||
|
type: params.type,
|
||||||
|
order: params.order,
|
||||||
|
payload: params.payload,
|
||||||
|
isActive: params.isActive !== false
|
||||||
|
};
|
||||||
|
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_page_section": {
|
||||||
|
const payload: any = {};
|
||||||
|
if (params.type) payload.type = params.type;
|
||||||
|
if (params.order !== undefined) payload.order = params.order;
|
||||||
|
if (params.payload) payload.payload = params.payload;
|
||||||
|
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
||||||
|
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "delete_page_section": {
|
||||||
|
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
|
"swiper": "^12.2.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import CouponsPage from './pages/CouponsPage'
|
|||||||
import OrderSuccessPage from './pages/OrderSuccessPage'
|
import OrderSuccessPage from './pages/OrderSuccessPage'
|
||||||
import TrackOrderPage from './pages/TrackOrderPage'
|
import TrackOrderPage from './pages/TrackOrderPage'
|
||||||
import WishlistPage from './pages/WishlistPage'
|
import WishlistPage from './pages/WishlistPage'
|
||||||
|
import CustomPage from './pages/CustomPage'
|
||||||
import CartDrawer from './components/CartDrawer'
|
import CartDrawer from './components/CartDrawer'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -50,6 +51,7 @@ function App() {
|
|||||||
<Route path="/article/:slug" element={<ArticleReaderPage />} />
|
<Route path="/article/:slug" element={<ArticleReaderPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
<Route path="/pages/:slug" element={<CustomPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Autoplay } from 'swiper/modules';
|
||||||
|
import type { AnnouncementBarSectionPayload } from '../types';
|
||||||
|
|
||||||
|
// Import Swiper styles
|
||||||
|
import 'swiper/css';
|
||||||
|
|
||||||
|
interface DynamicAnnouncementBarProps {
|
||||||
|
payload: AnnouncementBarSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
|
||||||
|
const { slides = [], backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
||||||
|
|
||||||
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full py-2.5 px-4 text-center text-[10px] sm:text-xs tracking-[0.2em] font-semibold uppercase relative z-30 overflow-hidden"
|
||||||
|
style={{ backgroundColor, color: textColor }}
|
||||||
|
>
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay]}
|
||||||
|
autoplay={{ delay: speed, disableOnInteraction: false }}
|
||||||
|
loop={slides.length > 1}
|
||||||
|
className="w-full select-none"
|
||||||
|
>
|
||||||
|
{slides.map((slide, idx) => (
|
||||||
|
<SwiperSlide key={idx} className="flex items-center justify-center">
|
||||||
|
<span className="inline-block truncate max-w-full">{slide}</span>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BannerSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface DynamicBannerProps {
|
||||||
|
payload: BannerSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
||||||
|
const { imageUrl, linkUrl, title, description, buttonText } = payload;
|
||||||
|
|
||||||
|
const Wrapper = linkUrl ? Link : 'div';
|
||||||
|
const wrapperProps = linkUrl ? { to: linkUrl } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6 bg-white relative overflow-hidden">
|
||||||
|
<Wrapper
|
||||||
|
{...(wrapperProps as any)}
|
||||||
|
className={`max-w-7xl mx-auto block brutal-box group overflow-hidden relative min-h-[350px] md:min-h-[400px] rounded-3xl cursor-${linkUrl ? 'pointer' : 'default'}`}
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||||
|
alt={title || "Banner"}
|
||||||
|
/>
|
||||||
|
{/* Overlay gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-spa-forest/80 via-spa-forest/40 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col justify-center p-8 md:p-16 max-w-xl text-white space-y-4">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-3xl md:text-5xl font-serif font-light text-spa-gold leading-tight">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-spa-light/95 font-light text-base md:text-lg">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{buttonText && linkUrl && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<span className="inline-block px-8 py-3 bg-white text-spa-forest font-semibold rounded-full tracking-wider text-sm shadow-lg group-hover:bg-spa-sage transition-all duration-300">
|
||||||
|
{buttonText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface DynamicCategoriesProps {
|
||||||
|
payload: CategoriesSectionPayload;
|
||||||
|
categories: Category[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicCategories({ payload, categories, loading }: DynamicCategoriesProps) {
|
||||||
|
const { title, subtitle, categoryIds } = payload;
|
||||||
|
|
||||||
|
// Filter categories if categoryIds list is provided
|
||||||
|
const displayedCategories = React.useMemo(() => {
|
||||||
|
if (categoryIds && categoryIds.length > 0) {
|
||||||
|
return categories.filter((cat) => categoryIds.includes(cat.id));
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}, [categories, categoryIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="categories" className="py-24 px-6 relative overflow-hidden bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-16 relative z-10">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-pop-black">
|
||||||
|
{title || "Our Collections"}
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-gray-500 max-w-2xl mx-auto">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-80 brutal-box bg-gray-50 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||||
|
{displayedCategories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
to={`/category/${cat.id}`}
|
||||||
|
className="relative h-80 rounded-2xl group overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<img
|
||||||
|
src={cat.imageUrl || getCategoryImage(cat.name)}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
|
alt={cat.name}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-6 left-6 right-6 z-20 text-white">
|
||||||
|
<span className="font-semibold text-xl block mb-1">
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
{cat._count && (
|
||||||
|
<span className="text-sm font-medium text-white/80">
|
||||||
|
{cat._count.products} Products
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Autoplay, Pagination } from 'swiper/modules';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { EditorialCarouselSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
// Import Swiper styles
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
|
||||||
|
interface DynamicEditorialCarouselProps {
|
||||||
|
payload: EditorialCarouselSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) {
|
||||||
|
const { title, slides = [] } = payload;
|
||||||
|
|
||||||
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
|
const renderTitle = (text: string) => {
|
||||||
|
if (!text) return null;
|
||||||
|
return text.split('[').map((part, i) => {
|
||||||
|
if (part.includes(']')) {
|
||||||
|
const [accent, rest] = part.split(']');
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
<span className="text-spa-gold font-medium italic">
|
||||||
|
{accent}
|
||||||
|
</span>
|
||||||
|
{rest}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full bg-[#1F3D2D] text-white py-20 px-6 md:px-16 overflow-hidden">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-12">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h2 className="text-3xl md:text-5xl font-light tracking-wide font-serif text-spa-gold">
|
||||||
|
{renderTitle(title) || title}
|
||||||
|
</h2>
|
||||||
|
<div className="w-16 h-[1px] bg-spa-gold mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Swiper Slider */}
|
||||||
|
<div className="relative select-none pb-12">
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay, Pagination]}
|
||||||
|
spaceBetween={30}
|
||||||
|
slidesPerView={1}
|
||||||
|
breakpoints={{
|
||||||
|
640: { slidesPerView: 2 },
|
||||||
|
1024: { slidesPerView: 3 }
|
||||||
|
}}
|
||||||
|
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-gold' }}
|
||||||
|
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{slides.map((slide, idx) => (
|
||||||
|
<SwiperSlide key={idx} className="h-auto flex flex-col">
|
||||||
|
<div className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden flex flex-col h-full group hover:border-spa-gold/30 transition-all duration-300">
|
||||||
|
{/* Image wrapper */}
|
||||||
|
<div className="aspect-[4/3] overflow-hidden relative">
|
||||||
|
<img
|
||||||
|
src={slide.imageUrl}
|
||||||
|
alt={slide.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#1F3D2D]/60 via-transparent to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content details */}
|
||||||
|
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-spa-gold block">
|
||||||
|
{slide.category}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-light font-serif tracking-wide text-spa-light">
|
||||||
|
{renderTitle(slide.title) || slide.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs md:text-sm font-light text-spa-light/70 leading-relaxed line-clamp-3">
|
||||||
|
{slide.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{slide.buttonText && slide.buttonLink && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Link
|
||||||
|
to={slide.buttonLink}
|
||||||
|
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-spa-gold hover:text-white transition-colors group/link"
|
||||||
|
>
|
||||||
|
{slide.buttonText}
|
||||||
|
<span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { EditorialTileSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface DynamicEditorialTileProps {
|
||||||
|
payload: EditorialTileSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicEditorialTile({ payload }: DynamicEditorialTileProps) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
imageUrl,
|
||||||
|
imagePosition = 'left',
|
||||||
|
buttonText,
|
||||||
|
buttonLink,
|
||||||
|
backgroundColor = '#FDFDF9', // Default to spa-light
|
||||||
|
textColor = '#1F3D2D' // Default to spa-forest
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
const renderTitle = (text: string) => {
|
||||||
|
if (!text) return null;
|
||||||
|
return text.split('[').map((part, i) => {
|
||||||
|
if (part.includes(']')) {
|
||||||
|
const [accent, rest] = part.split(']');
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
<span className="text-spa-gold font-medium italic">
|
||||||
|
{accent}
|
||||||
|
</span>
|
||||||
|
{rest}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="w-full overflow-hidden py-16 md:py-24 px-6 md:px-16"
|
||||||
|
style={{ backgroundColor, color: textColor }}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-20 items-center">
|
||||||
|
{/* Image Column */}
|
||||||
|
<div className={`w-full overflow-hidden rounded-3xl shadow-xl aspect-square relative group ${
|
||||||
|
imagePosition === 'left' ? 'md:order-1' : 'md:order-2'
|
||||||
|
}`}>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Subtle overlay */}
|
||||||
|
<div className="absolute inset-0 bg-neutral-900/5 mix-blend-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Column */}
|
||||||
|
<div className={`space-y-6 md:space-y-8 flex flex-col justify-center ${
|
||||||
|
imagePosition === 'left' ? 'md:order-2 md:pl-8' : 'md:order-1 md:pr-8'
|
||||||
|
}`}>
|
||||||
|
<h2 className="text-3xl md:text-5xl font-light leading-tight tracking-wide font-serif">
|
||||||
|
{renderTitle(title) || title}
|
||||||
|
</h2>
|
||||||
|
<div className="w-12 h-[1px] bg-spa-gold"></div>
|
||||||
|
<p className="text-sm md:text-base font-light leading-relaxed opacity-90 whitespace-pre-line">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{buttonText && buttonLink && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link
|
||||||
|
to={buttonLink}
|
||||||
|
className="inline-block px-8 py-3.5 border border-current rounded-full font-bold uppercase tracking-widest text-xs hover:bg-spa-gold hover:border-spa-gold hover:text-white transition-all duration-300"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { HeroSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface DynamicHeroProps {
|
||||||
|
payload: HeroSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||||
|
const { tagline, title, description, imageUrl, buttonText, buttonLink } = payload;
|
||||||
|
|
||||||
|
const renderTitle = (text: string) => {
|
||||||
|
if (!text) return null;
|
||||||
|
return text.split('[').map((part, i) => {
|
||||||
|
if (part.includes(']')) {
|
||||||
|
const [accent, rest] = part.split(']');
|
||||||
|
return (
|
||||||
|
<span key={i}>
|
||||||
|
<span className="text-spa-gold font-medium italic">
|
||||||
|
{accent}
|
||||||
|
</span>
|
||||||
|
{rest}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={imageUrl || "/images/hero.png"}
|
||||||
|
className="w-full h-full object-cover scale-[1.02]"
|
||||||
|
alt="Hero Background"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Dark gradient overlay for premium vibe */}
|
||||||
|
<div className="absolute inset-0 bg-neutral-900/40"></div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{tagline || "Vibrant Elegance Collection"}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
|
||||||
|
{renderTitle(title) || (
|
||||||
|
<>
|
||||||
|
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<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">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6 items-center justify-center">
|
||||||
|
<Link
|
||||||
|
to={buttonLink || "/shop"}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{buttonText || "Explore Collection"}
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="#categories"
|
||||||
|
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
|
||||||
|
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { API_BASE, type NewsletterSectionPayload } from '../types';
|
||||||
|
|
||||||
|
interface DynamicNewsletterProps {
|
||||||
|
payload: NewsletterSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
buttonText = 'Subscribe',
|
||||||
|
placeholderText = 'Enter your email address',
|
||||||
|
showPrivacyCheckbox = true,
|
||||||
|
privacyText = 'I agree to the privacy policy.'
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [agreed, setAgreed] = useState(!showPrivacyCheckbox);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (showPrivacyCheckbox && !agreed) {
|
||||||
|
alert('Please agree to the privacy policy.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('submitting');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/pages/newsletter`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setStatus('success');
|
||||||
|
setEmail('');
|
||||||
|
} else {
|
||||||
|
const errData = await res.json();
|
||||||
|
setErrorMessage(errData.error || 'Failed to subscribe.');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Newsletter submission error:', err);
|
||||||
|
setErrorMessage('Network error. Please try again.');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-20 px-6 bg-spa-forest text-white relative overflow-hidden">
|
||||||
|
{/* Background decorations for luxury aesthetic */}
|
||||||
|
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-10 left-10 w-96 h-96 rounded-full bg-spa-gold filter blur-3xl"></div>
|
||||||
|
<div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-spa-sage filter blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto text-center space-y-8 relative z-10">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-3xl md:text-5xl font-serif font-light tracking-wide text-spa-gold">
|
||||||
|
{title || "Begin Your Wellness Journey"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-spa-light/80 text-sm md:text-base font-light max-w-xl mx-auto">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'success' ? (
|
||||||
|
<div className="p-6 bg-white/5 border border-spa-gold/30 rounded-2xl animate-pop-in space-y-2">
|
||||||
|
<h4 className="text-spa-gold font-semibold text-lg">Thank You for Subscribing</h4>
|
||||||
|
<p className="text-xs text-spa-light/75">You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
placeholder={placeholderText}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="flex-1 px-6 py-4 bg-white/5 border border-white/10 rounded-full text-white placeholder-white/40 focus:outline-none focus:border-spa-gold text-sm transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
className="px-8 py-4 bg-white text-spa-forest hover:bg-spa-sage rounded-full font-bold text-sm tracking-widest uppercase transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? 'Submitting...' : buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<p className="text-rose-400 text-xs text-left px-4">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPrivacyCheckbox && (
|
||||||
|
<label className="flex items-start gap-3 text-[11px] text-spa-light/60 text-left px-4 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={agreed}
|
||||||
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-spa-gold"
|
||||||
|
/>
|
||||||
|
<span>{privacyText}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { PageSection, Category } from '@homaroma/types';
|
||||||
|
import DynamicHero from './DynamicHero';
|
||||||
|
import DynamicCategories from './DynamicCategories';
|
||||||
|
import DynamicProducts from './DynamicProducts';
|
||||||
|
import DynamicBanner from './DynamicBanner';
|
||||||
|
import DynamicText from './DynamicText';
|
||||||
|
import DynamicAnnouncementBar from './DynamicAnnouncementBar';
|
||||||
|
import DynamicNewsletter from './DynamicNewsletter';
|
||||||
|
import DynamicEditorialTile from './DynamicEditorialTile';
|
||||||
|
import DynamicEditorialCarousel from './DynamicEditorialCarousel';
|
||||||
|
|
||||||
|
interface DynamicPageRendererProps {
|
||||||
|
sections: PageSection[];
|
||||||
|
categories: Category[];
|
||||||
|
categoriesLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicPageRenderer({
|
||||||
|
sections,
|
||||||
|
categories,
|
||||||
|
categoriesLoading
|
||||||
|
}: DynamicPageRendererProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{sections.map((section) => {
|
||||||
|
if (!section.isActive) return null;
|
||||||
|
|
||||||
|
switch (section.type.toUpperCase()) {
|
||||||
|
case 'ANNOUNCEMENT_BAR':
|
||||||
|
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||||
|
case 'NEWSLETTER':
|
||||||
|
return <DynamicNewsletter key={section.id} payload={section.payload} />;
|
||||||
|
case 'HERO':
|
||||||
|
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||||
|
case 'CATEGORIES':
|
||||||
|
return (
|
||||||
|
<DynamicCategories
|
||||||
|
key={section.id}
|
||||||
|
payload={section.payload}
|
||||||
|
categories={categories}
|
||||||
|
loading={categoriesLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'PRODUCTS':
|
||||||
|
return <DynamicProducts key={section.id} payload={section.payload} />;
|
||||||
|
case 'BANNER':
|
||||||
|
return <DynamicBanner key={section.id} payload={section.payload} />;
|
||||||
|
case 'TEXT':
|
||||||
|
return <DynamicText key={section.id} payload={section.payload} />;
|
||||||
|
case 'EDITORIAL_TILE':
|
||||||
|
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
|
||||||
|
case 'EDITORIAL_CAROUSEL':
|
||||||
|
return <DynamicEditorialCarousel key={section.id} payload={section.payload} />;
|
||||||
|
default:
|
||||||
|
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Autoplay, Pagination } from 'swiper/modules';
|
||||||
|
import ProductCard from './ProductCard';
|
||||||
|
import type { Product, ProductsSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
// Import Swiper styles
|
||||||
|
import 'swiper/css';
|
||||||
|
import 'swiper/css/pagination';
|
||||||
|
|
||||||
|
interface DynamicProductsProps {
|
||||||
|
payload: ProductsSectionPayload & { products?: Product[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||||
|
const { title, subtitle, products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-pop-bg py-24 px-6 relative">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-12 relative z-10">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-end gap-6 border-b border-gray-200 pb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold text-pop-black">
|
||||||
|
{title || "Featured Picks"}
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-gray-500 mt-2">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/shop"
|
||||||
|
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
View All Collection →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No products found in this section
|
||||||
|
</div>
|
||||||
|
) : layout === 'carousel' ? (
|
||||||
|
<div className="relative select-none pb-12">
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay, Pagination]}
|
||||||
|
spaceBetween={24}
|
||||||
|
slidesPerView={1}
|
||||||
|
breakpoints={{
|
||||||
|
640: { slidesPerView: 2 },
|
||||||
|
1024: { slidesPerView: 3 }
|
||||||
|
}}
|
||||||
|
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-forest' }}
|
||||||
|
autoplay={{ delay: 6000, disableOnInteraction: false }}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SwiperSlide key={p.id} className="h-auto">
|
||||||
|
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{products.map((p) => (
|
||||||
|
<div key={p.id}>
|
||||||
|
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { TextSectionPayload } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface DynamicTextProps {
|
||||||
|
payload: TextSectionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicText({ payload }: DynamicTextProps) {
|
||||||
|
const { title, content, textAlign = 'left' } = payload;
|
||||||
|
|
||||||
|
const alignClass = {
|
||||||
|
left: 'text-left',
|
||||||
|
center: 'text-center',
|
||||||
|
right: 'text-right',
|
||||||
|
}[textAlign] || 'text-left';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6 bg-white relative">
|
||||||
|
<div className={`max-w-4xl mx-auto space-y-6 ${alignClass}`}>
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-3xl md:text-4xl font-serif text-spa-forest font-semibold">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="text-gray-600 leading-relaxed text-base md:text-lg font-light whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,51 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { Product } from '../types'
|
import { useState } from 'react';
|
||||||
import { getCategoryImage, formatPrice } from '../types'
|
import type { Product } from '../types';
|
||||||
import { Heart } from 'lucide-react'
|
import { getCategoryImage, formatPrice } from '../types';
|
||||||
import { useWishlistStore } from '../store/wishlistStore'
|
import { Heart, ShoppingBag } from 'lucide-react';
|
||||||
|
import { useWishlistStore } from '../store/wishlistStore';
|
||||||
|
import { useCartStore } from '../store/cartStore';
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product
|
product: Product;
|
||||||
|
enableQuickShop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product, enableQuickShop = false }: ProductCardProps) {
|
||||||
const { addItem, removeItem, isInWishlist } = useWishlistStore()
|
const navigate = useNavigate();
|
||||||
const isWished = isInWishlist(product.id)
|
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
||||||
|
const { addToCart } = useCartStore();
|
||||||
|
const isWished = isInWishlist(product.id);
|
||||||
|
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||||
|
|
||||||
const handleWishlistClick = (e: React.MouseEvent) => {
|
const handleWishlistClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault() // prevent navigating to product detail
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (isWished) {
|
if (isWished) {
|
||||||
removeItem(product.id)
|
removeItem(product.id);
|
||||||
} else {
|
} else {
|
||||||
addItem(product)
|
addItem(product);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(`/products/${product.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
to={`/products/${product.id}`}
|
onClick={handleCardClick}
|
||||||
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"
|
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 cursor-pointer"
|
||||||
>
|
>
|
||||||
|
{/* Image Area */}
|
||||||
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
||||||
<img
|
<img
|
||||||
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
|
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
alt={product.scent.name}
|
alt={product.scent.name}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Category badge */}
|
{/* Category badge */}
|
||||||
<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">
|
<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">
|
||||||
@@ -38,8 +53,9 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
{/* Wishlist Button */}
|
{/* Wishlist Button */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleWishlistClick}
|
onClick={handleWishlistClick}
|
||||||
className="absolute top-3 right-3 p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:scale-110 transition-transform z-10"
|
className="absolute top-3 right-3 p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:scale-110 transition-transform z-10 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`}
|
className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`}
|
||||||
@@ -47,8 +63,9 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
<div className="p-4 flex flex-col flex-1">
|
<div className="p-4 flex flex-col flex-1">
|
||||||
<div className="mb-4">
|
<div className="mb-2">
|
||||||
<h3 className="text-base md:text-lg font-bold text-pop-black leading-tight line-clamp-1 group-hover:text-spa-forest transition-colors">
|
<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}
|
{product.scent.name}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -57,15 +74,70 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between">
|
{/* Quick Shop Variant Swatches */}
|
||||||
<span className="text-rose-500 font-black text-sm md:text-lg">
|
{enableQuickShop && product.variants && product.variants.length > 0 && (
|
||||||
{formatPrice(product.variants[0]?.price)}
|
<div className="flex flex-wrap gap-1.5 mt-2 mb-1">
|
||||||
</span>
|
{product.variants.map((variant) => {
|
||||||
<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">
|
const isSelected = selectedVariant?.id === variant.id;
|
||||||
View Details →
|
return (
|
||||||
</span>
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedVariant(variant);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1 rounded-full text-[10px] font-bold tracking-wider uppercase border transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-spa-forest border-spa-forest text-white shadow-sm'
|
||||||
|
: 'bg-white border-spa-stone/10 text-spa-stone hover:border-spa-forest/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{variant.size}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Area */}
|
||||||
|
<div className="mt-auto pt-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-rose-500 font-black text-sm md:text-lg">
|
||||||
|
{formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
|
||||||
|
</span>
|
||||||
|
{!enableQuickShop && (
|
||||||
|
<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 →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enableQuickShop && selectedVariant && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
addToCart({
|
||||||
|
productId: product.id,
|
||||||
|
variantId: selectedVariant.id,
|
||||||
|
quantity: 1,
|
||||||
|
price: selectedVariant.price || 0,
|
||||||
|
productName: product.scent.name,
|
||||||
|
variantSize: selectedVariant.size,
|
||||||
|
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-xl font-bold text-xs uppercase tracking-widest transition-all shadow-sm hover:shadow flex items-center justify-center gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-3.5 h-3.5" />
|
||||||
|
Add to Cart
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,8 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
const [rating, setRating] = useState(5)
|
const [rating, setRating] = useState(5)
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [filterRating, setFilterRating] = useState<number | null>(null)
|
||||||
|
const [visibleCount, setVisibleCount] = useState(3)
|
||||||
|
|
||||||
const fetchReviews = () => {
|
const fetchReviews = () => {
|
||||||
fetch(`${API_BASE}/reviews/product/${productId}`)
|
fetch(`${API_BASE}/reviews/product/${productId}`)
|
||||||
@@ -68,6 +70,12 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
? (reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length).toFixed(1)
|
? (reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length).toFixed(1)
|
||||||
: '0.0'
|
: '0.0'
|
||||||
|
|
||||||
|
const filteredReviews = filterRating
|
||||||
|
? reviews.filter(r => r.rating === filterRating)
|
||||||
|
: reviews
|
||||||
|
|
||||||
|
const visibleReviews = filteredReviews.slice(0, visibleCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-16 border-t border-spa-stone/10 space-y-12">
|
<div className="pt-16 border-t border-spa-stone/10 space-y-12">
|
||||||
<div className="flex flex-col md:flex-row gap-12 items-start">
|
<div className="flex flex-col md:flex-row gap-12 items-start">
|
||||||
@@ -118,12 +126,47 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Filter Bar */}
|
||||||
|
{reviews.length > 0 && !loading && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilterRating(null); setVisibleCount(3); }}
|
||||||
|
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||||
|
filterRating === null
|
||||||
|
? 'bg-spa-stone text-white border-spa-stone'
|
||||||
|
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({reviews.length})
|
||||||
|
</button>
|
||||||
|
{[5, 4, 3, 2, 1].map(star => {
|
||||||
|
const count = reviews.filter(r => r.rating === star).length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`filter-${star}`}
|
||||||
|
onClick={() => { setFilterRating(star); setVisibleCount(3); }}
|
||||||
|
className={`flex-shrink-0 flex items-center gap-1 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||||
|
filterRating === star
|
||||||
|
? 'bg-spa-stone text-white border-spa-stone'
|
||||||
|
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{star} <Star className={`w-3 h-3 ${filterRating === star ? 'fill-white text-white' : 'fill-orange-400 text-orange-400'}`} /> ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-spa-stone animate-pulse text-sm">Loading reviews...</p>
|
<p className="text-spa-stone animate-pulse text-sm">Loading reviews...</p>
|
||||||
) : reviews.length === 0 ? (
|
) : reviews.length === 0 ? (
|
||||||
<p className="text-spa-stone/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
<p className="text-spa-stone/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
||||||
|
) : visibleReviews.length === 0 ? (
|
||||||
|
<p className="text-spa-stone/60 text-sm italic">No reviews found for this rating.</p>
|
||||||
) : (
|
) : (
|
||||||
reviews.map(review => (
|
visibleReviews.map(review => (
|
||||||
<div key={review.id} className="border-b border-spa-stone/10 pb-6 last:border-0">
|
<div key={review.id} className="border-b border-spa-stone/10 pb-6 last:border-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{review.customer?.pictureUrl ? (
|
{review.customer?.pictureUrl ? (
|
||||||
@@ -147,6 +190,17 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{visibleCount < filteredReviews.length && (
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleCount(prev => prev + 5)}
|
||||||
|
className="px-6 py-2.5 border border-spa-stone/20 text-spa-stone text-xs font-bold tracking-widest uppercase rounded-full hover:border-spa-stone hover:text-pop-black transition-all bg-white"
|
||||||
|
>
|
||||||
|
Show More Reviews
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useParams, Link } from 'react-router-dom'
|
|||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import { API_BASE, getCategoryImage, type Category, type Product } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
|
||||||
|
|
||||||
export default function CategoryPage() {
|
export default function CategoryPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -11,7 +12,8 @@ export default function CategoryPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [category, setCategory] = useState<Category | null>(null)
|
const [category, setCategory] = useState<Category | null>(null)
|
||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
// Local progressive rendering
|
// Local progressive rendering
|
||||||
const [displayCount, setDisplayCount] = useState(24)
|
const [displayCount, setDisplayCount] = useState(24)
|
||||||
const loaderRef = useRef<HTMLDivElement>(null)
|
const loaderRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -26,6 +28,22 @@ export default function CategoryPage() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/category-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Category dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Reset state when category changes
|
// Reset state when category changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
@@ -73,45 +91,67 @@ export default function CategoryPage() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [products.length, displayCount, loading])
|
}, [products.length, displayCount, loading])
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Hero Banner for category */}
|
{/* Hero Banner for category */}
|
||||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
{heroSections.length > 0 ? (
|
||||||
<img
|
<div className="pt-20">
|
||||||
src={getCategoryImage(category?.name ?? '')}
|
<DynamicPageRenderer
|
||||||
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
|
sections={heroSections}
|
||||||
alt={category?.name}
|
categories={categories}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
categoriesLoading={loading}
|
||||||
/>
|
/>
|
||||||
<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-transparent" />
|
) : (
|
||||||
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
|
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||||
<div className="max-w-7xl mx-auto">
|
<img
|
||||||
{/* Breadcrumb */}
|
src={getCategoryImage(category?.name ?? '')}
|
||||||
<div className="flex items-center gap-2 text-xs text-spa-sage/80 uppercase tracking-widest mb-4 font-semibold">
|
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
|
||||||
<Link to="/" className="hover:text-white transition-colors">Home</Link>
|
alt={category?.name}
|
||||||
<span>/</span>
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
|
/>
|
||||||
<span>/</span>
|
<div className="absolute inset-0 bg-night-900/40" />
|
||||||
<span className="text-white">{category?.name ?? '...'}</span>
|
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-transparent" />
|
||||||
</div>
|
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white">
|
<div className="max-w-7xl mx-auto">
|
||||||
{loading ? (
|
{/* Breadcrumb */}
|
||||||
<div className="h-10 w-48 bg-white/20 animate-pulse rounded-lg" />
|
<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>
|
||||||
category?.name
|
<span>/</span>
|
||||||
|
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white">{category?.name ?? '...'}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-white">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-10 w-48 bg-white/20 animate-pulse rounded-lg" />
|
||||||
|
) : (
|
||||||
|
category?.name
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
{!loading && category?._count && (
|
||||||
|
<p className="text-spa-sage mt-2">
|
||||||
|
{category._count.products} products available
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</div>
|
||||||
{!loading && category?._count && (
|
|
||||||
<p className="text-spa-sage mt-2">
|
|
||||||
{category._count.products} products available
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Products Grid */}
|
{/* Products Grid */}
|
||||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
|
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
|
||||||
@@ -169,6 +209,14 @@ export default function CategoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import { API_BASE, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
|
|
||||||
type FormState = { name: string; email: string; subject: string; message: string }
|
type FormState = { name: string; email: string; subject: string; message: string }
|
||||||
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||||
@@ -12,11 +13,28 @@ export default function ContactPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||||
const [status, setStatus] = useState<Status>('idle')
|
const [status, setStatus] = useState<Status>('idle')
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/contact-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Contact dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||||
}
|
}
|
||||||
@@ -30,18 +48,40 @@ export default function ContactPage() {
|
|||||||
setForm({ name: '', email: '', subject: '', message: '' })
|
setForm({ name: '', email: '', subject: '', message: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
|
{heroSections.length > 0 ? (
|
||||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
|
<div className="pt-20">
|
||||||
<h1 className="text-5xl font-bold">Contact Us</h1>
|
<DynamicPageRenderer
|
||||||
<p className="text-slate-400 max-w-lg mx-auto">
|
sections={heroSections}
|
||||||
Have a question, custom request, or partnership idea? We'd love to hear from you.
|
categories={categories}
|
||||||
</p>
|
categoriesLoading={false}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
|
||||||
|
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
|
||||||
|
<h1 className="text-5xl font-bold">Contact Us</h1>
|
||||||
|
<p className="text-slate-400 max-w-lg mx-auto">
|
||||||
|
Have a question, custom request, or partnership idea? We'd love to hear from you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
|
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
|
||||||
{/* Contact Info */}
|
{/* Contact Info */}
|
||||||
@@ -177,6 +217,14 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { API_BASE, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
import { Ticket, Copy, CheckCircle2, ArrowRight } from 'lucide-react'
|
import { Ticket, Copy, CheckCircle2, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
interface Coupon {
|
interface Coupon {
|
||||||
@@ -23,6 +24,28 @@ export default function CouponsPage() {
|
|||||||
const [coupons, setCoupons] = useState<Coupon[]>([])
|
const [coupons, setCoupons] = useState<Coupon[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/categories`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(setCategories)
|
||||||
|
.catch(console.error)
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/pages/coupons-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Coupons dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`)
|
fetch(`${API_BASE}/categories`)
|
||||||
@@ -61,19 +84,40 @@ export default function CouponsPage() {
|
|||||||
navigate('/shop')
|
navigate('/shop')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-pop-bg">
|
<div className="min-h-screen bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
|
<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">
|
{/* Header */}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-pop-black">
|
{heroSections.length > 0 ? (
|
||||||
Exclusive Offers
|
<DynamicPageRenderer
|
||||||
</h1>
|
sections={heroSections}
|
||||||
<p className="text-spa-stone max-w-2xl mx-auto">
|
categories={categories}
|
||||||
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
categoriesLoading={false}
|
||||||
</p>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="text-center space-y-4 mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-pop-black">
|
||||||
|
Exclusive Offers
|
||||||
|
</h1>
|
||||||
|
<p className="text-spa-stone max-w-2xl mx-auto">
|
||||||
|
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -154,6 +198,14 @@ export default function CouponsPage() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import Navbar from '../components/Navbar';
|
||||||
|
import Footer from '../components/Footer';
|
||||||
|
import DynamicPageRenderer from '../components/DynamicPageRenderer';
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types';
|
||||||
|
|
||||||
|
export default function CustomPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [sections, setSections] = useState<PageSection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [categoriesLoading, setCategoriesLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/categories`);
|
||||||
|
const cats: Category[] = await res.json();
|
||||||
|
setCategories(cats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Categories error:', err);
|
||||||
|
} finally {
|
||||||
|
setCategoriesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPageLayout = async () => {
|
||||||
|
if (!slug) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/pages/${slug}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Page not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pageData = await res.json();
|
||||||
|
if (pageData && !pageData.error) {
|
||||||
|
setSections(pageData.sections || []);
|
||||||
|
} else {
|
||||||
|
setError(pageData.error || 'Page not found');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Page layout error:', err);
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
fetchPageLayout();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#FDFDF9] text-[#1F3D2D]">
|
||||||
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 border-4 border-spa-forest border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="min-h-[60vh] flex flex-col items-center justify-center space-y-6 px-6 text-center">
|
||||||
|
<h2 className="text-3xl font-serif font-light tracking-wide text-spa-forest">
|
||||||
|
Sensory Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-spa-stone/70 text-sm max-w-md font-light">
|
||||||
|
The page you are looking for does not exist or may have been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="px-8 py-3 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-full font-bold text-xs uppercase tracking-widest transition-all"
|
||||||
|
>
|
||||||
|
Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={sections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={categoriesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Footer categories={categories} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import { API_BASE, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
|
|
||||||
const FAQS = [
|
const FAQS = [
|
||||||
{
|
{
|
||||||
@@ -46,27 +47,66 @@ export default function FaqPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/faq-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('FAQ dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
|
||||||
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
|
{heroSections.length > 0 ? (
|
||||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
|
<div className="pt-20">
|
||||||
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
|
<DynamicPageRenderer
|
||||||
<p className="text-slate-400 max-w-lg mx-auto">
|
sections={heroSections}
|
||||||
Everything you need to know about HomAroma products, shipping, and care.
|
categories={categories}
|
||||||
</p>
|
categoriesLoading={false}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
|
||||||
|
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
|
||||||
|
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
|
||||||
|
<p className="text-slate-400 max-w-lg mx-auto">
|
||||||
|
Everything you need to know about HomAroma products, shipping, and care.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
|
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
@@ -133,6 +173,14 @@ export default function FaqPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+35
-167
@@ -1,193 +1,61 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
import { API_BASE, getCategoryImage, type Category, type Product, type Hero } from '../types'
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [featuredProducts, setFeaturedProducts] = useState<Product[]>([])
|
const [sections, setSections] = useState<PageSection[]>([])
|
||||||
const [hero, setHero] = useState<Hero | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const [catRes, prodRes, heroRes] = await Promise.all([
|
const res = await fetch(`${API_BASE}/categories`)
|
||||||
fetch(`${API_BASE}/categories`),
|
const cats: Category[] = await res.json()
|
||||||
fetch(`${API_BASE}/products?categoryId=1`),
|
|
||||||
fetch(`${API_BASE}/hero`),
|
|
||||||
])
|
|
||||||
const cats: Category[] = await catRes.json()
|
|
||||||
const prodsJson = await prodRes.json()
|
|
||||||
const heroText = await heroRes.text()
|
|
||||||
const heroData: Hero | null = heroText ? JSON.parse(heroText) : null
|
|
||||||
|
|
||||||
const prods: Product[] = prodsJson.data || []
|
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
setFeaturedProducts(prods.slice(0, 3))
|
|
||||||
setHero(heroData)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('API error:', err)
|
console.error('Categories error:', err)
|
||||||
|
} finally {
|
||||||
|
setCategoriesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPageLayout = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/pages/home`)
|
||||||
|
const pageData = await res.json()
|
||||||
|
if (pageData && pageData.sections) {
|
||||||
|
setSections(pageData.sections)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Page layout error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData()
|
|
||||||
|
fetchCategories()
|
||||||
|
fetchPageLayout()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-pop-bg">
|
<div className="min-h-screen bg-pop-bg">
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{loading ? (
|
||||||
<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="min-h-[60vh] flex items-center justify-center">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="w-12 h-12 border-4 border-pop-pink border-t-transparent rounded-full animate-spin"></div>
|
||||||
<img
|
|
||||||
src={hero?.imageUrl || "/images/hero.png"}
|
|
||||||
className="w-full h-full object-cover scale-[1.02]"
|
|
||||||
alt="Hero Background"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 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>
|
||||||
|
) : (
|
||||||
<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">
|
<DynamicPageRenderer
|
||||||
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
|
sections={sections}
|
||||||
{hero?.tagline || "Vibrant Elegance Collection"}
|
categories={categories}
|
||||||
</p>
|
categoriesLoading={categoriesLoading}
|
||||||
<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-spa-gold font-medium italic">
|
|
||||||
{accent}
|
|
||||||
</span>
|
|
||||||
{rest}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <span key={i}>{part}</span>
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
<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-6 items-center justify-center">
|
|
||||||
{/* White solid primary button */}
|
|
||||||
<Link
|
|
||||||
to={hero?.buttonLink || "/shop"}
|
|
||||||
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-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
|
|
||||||
>
|
|
||||||
Learn More
|
|
||||||
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Categories Showcase */}
|
|
||||||
<section id="categories" className="py-24 px-6 relative overflow-hidden bg-white">
|
|
||||||
<div className="max-w-7xl mx-auto space-y-16 relative z-10">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-pop-black">
|
|
||||||
Our Collections
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 max-w-2xl mx-auto">Discover the perfect scent for every moment</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-80 brutal-box bg-gray-50 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Link
|
|
||||||
key={cat.id}
|
|
||||||
to={`/category/${cat.id}`}
|
|
||||||
className="relative h-80 rounded-2xl group overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
|
||||||
<img
|
|
||||||
src={getCategoryImage(cat.name)}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
|
||||||
alt={cat.name}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-6 left-6 right-6 z-20 text-white">
|
|
||||||
<span className="font-semibold text-xl block mb-1">
|
|
||||||
{cat.name}
|
|
||||||
</span>
|
|
||||||
{cat._count && (
|
|
||||||
<span className="text-sm font-medium text-white/80">
|
|
||||||
{cat._count.products} Products
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Featured Products */}
|
|
||||||
<section className="bg-pop-bg py-24 px-6 relative">
|
|
||||||
<div className="max-w-7xl mx-auto space-y-12 relative z-10">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-end gap-6 border-b border-gray-200 pb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-4xl md:text-5xl font-bold text-pop-black">
|
|
||||||
Featured Picks
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/shop"
|
|
||||||
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
View All Collection →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="brutal-box bg-white animate-pulse h-96" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{featuredProducts.map((p) => (
|
|
||||||
<div key={p.id}>
|
|
||||||
<ProductCard product={p} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category, type PageSection } from '../types'
|
||||||
import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react'
|
import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react'
|
||||||
|
|
||||||
export default function OrderSuccessPage() {
|
export default function OrderSuccessPage() {
|
||||||
@@ -14,17 +15,22 @@ export default function OrderSuccessPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
|
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
|
||||||
fetch(`${API_BASE}/settings`).then(r => r.json()),
|
fetch(`${API_BASE}/settings`).then(r => r.json()),
|
||||||
fetch(`${API_BASE}/categories`).then(r => r.json())
|
fetch(`${API_BASE}/categories`).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE}/pages/order-success-layout`).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||||
])
|
])
|
||||||
.then(([orderData, settingsData, catData]) => {
|
.then(([orderData, settingsData, catData, pageData]) => {
|
||||||
setOrder(orderData)
|
setOrder(orderData)
|
||||||
setSettings(settingsData)
|
setSettings(settingsData)
|
||||||
setCategories(catData)
|
setCategories(catData)
|
||||||
|
if (pageData && pageData.sections) {
|
||||||
|
setCmsSections(pageData.sections)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -92,8 +98,19 @@ export default function OrderSuccessPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-pop-bg">
|
<div className="min-h-screen bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in text-pop-black">
|
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in text-pop-black">
|
||||||
@@ -221,6 +238,14 @@ export default function OrderSuccessPage() {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
|
|||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import ProductReviews from '../components/ProductReviews'
|
import ProductReviews from '../components/ProductReviews'
|
||||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection } from '../types'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
@@ -16,6 +17,7 @@ export default function ProductDetailPage() {
|
|||||||
const [selectedVariant, setSelectedVariant] = useState<number>(0)
|
const [selectedVariant, setSelectedVariant] = useState<number>(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
const { addToCart } = useCartStore()
|
const { addToCart } = useCartStore()
|
||||||
|
|
||||||
@@ -26,6 +28,22 @@ export default function ProductDetailPage() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/product-detail-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Product detail dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -100,8 +118,19 @@ export default function ProductDetailPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md: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">
|
||||||
@@ -261,6 +290,14 @@ export default function ProductDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
|
|
||||||
{/* Mobile Sticky Add to Cart Bar */}
|
{/* Mobile Sticky Add to Cart Bar */}
|
||||||
|
|||||||
@@ -2,29 +2,69 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import { API_BASE, type Category, type Article } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type Article, type PageSection } from '../types'
|
||||||
|
|
||||||
export default function ScentGuidePage() {
|
export default function ScentGuidePage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [articles, setArticles] = useState<Article[]>([])
|
const [articles, setArticles] = useState<Article[]>([])
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||||
fetch(`${API_BASE}/articles?publishedOnly=true`).then(r => r.json()).then(setArticles).catch(console.error)
|
fetch(`${API_BASE}/articles?publishedOnly=true`).then(r => r.json()).then(setArticles).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/scent-guide-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Scent guide dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
|
{heroSections.length > 0 ? (
|
||||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
|
<div className="pt-20">
|
||||||
<h1 className="text-5xl font-bold">Scent Guide</h1>
|
<DynamicPageRenderer
|
||||||
<p className="text-slate-400 max-w-lg mx-auto">
|
sections={heroSections}
|
||||||
Explore our collection of articles, guides, and sensory knowledge.
|
categories={categories}
|
||||||
</p>
|
categoriesLoading={false}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
|
||||||
|
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
|
||||||
|
<h1 className="text-5xl font-bold">Scent Guide</h1>
|
||||||
|
<p className="text-slate-400 max-w-lg mx-auto">
|
||||||
|
Explore our collection of articles, guides, and sensory knowledge.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 max-w-7xl mx-auto px-6 py-16 w-full">
|
<div className="flex-1 max-w-7xl mx-auto px-6 py-16 w-full">
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
@@ -62,6 +102,14 @@ export default function ScentGuidePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
|
|||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { API_BASE, type Category, type Product } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type Product, type PageSection } from '../types'
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
@@ -15,11 +16,28 @@ export default function SearchPage() {
|
|||||||
const [products, setProducts] = useState<Product[]>([])
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [searched, setSearched] = useState(false)
|
const [searched, setSearched] = useState(false)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/search-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Search dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (!q.trim()) { setProducts([]); setSearched(false); return }
|
if (!q.trim()) { setProducts([]); setSearched(false); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -46,54 +64,76 @@ export default function SearchPage() {
|
|||||||
setSearchParams(input.trim() ? { q: input.trim() } : {})
|
setSearchParams(input.trim() ? { q: input.trim() } : {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-pop-bg">
|
<div className="min-h-screen bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Search Header */}
|
{/* Search Header */}
|
||||||
<div className="pt-32 pb-16 px-6 bg-spa-light border-b border-spa-stone/10">
|
{heroSections.length > 0 ? (
|
||||||
<div className="max-w-3xl mx-auto space-y-6 text-center">
|
<div className="pt-20">
|
||||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
|
<DynamicPageRenderer
|
||||||
Discover
|
sections={heroSections}
|
||||||
</p>
|
categories={categories}
|
||||||
<h1 className="text-5xl font-bold text-pop-black">Search Products</h1>
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
|
||||||
|
Discover
|
||||||
|
</p>
|
||||||
|
<h1 className="text-5xl font-bold text-pop-black">Search Products</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="relative">
|
<form onSubmit={handleSubmit} className="relative">
|
||||||
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Search by scent name, category..."
|
placeholder="Search by scent name, category..."
|
||||||
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-pop-black placeholder-spa-stone/60"
|
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-pop-black placeholder-spa-stone/60"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-spa-forest text-white px-8 font-bold text-xs uppercase tracking-widest hover:bg-spa-stone 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
|
Search
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Quick category links */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
to={`/search?q=${encodeURIComponent(cat.name)}`}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Quick category links */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Link
|
|
||||||
key={cat.id}
|
|
||||||
to={`/search?q=${encodeURIComponent(cat.name)}`}
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-8">
|
<div className="max-w-7xl mx-auto px-6 py-16 space-y-8">
|
||||||
@@ -131,6 +171,14 @@ export default function SearchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
|
|||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { API_BASE, type Category, type Product } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type Product, type PageSection } from '../types'
|
||||||
|
|
||||||
export default function ShopPage() {
|
export default function ShopPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
@@ -22,6 +23,7 @@ export default function ShopPage() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [productsLoading, setProductsLoading] = useState(false)
|
const [productsLoading, setProductsLoading] = useState(false)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
// Handle coupon from URL
|
// Handle coupon from URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +47,23 @@ export default function ShopPage() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch CMS sections for shop-layout
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/shop-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Shop dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Debounce search input
|
// Debounce search input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -95,22 +114,62 @@ export default function ShopPage() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [products.length, displayCount, productsLoading])
|
}, [products.length, displayCount, productsLoading])
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-spa-light">
|
{heroSections.length > 0 ? (
|
||||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
|
<div className="pt-20">
|
||||||
All Products
|
<DynamicPageRenderer
|
||||||
</p>
|
sections={heroSections}
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-pop-black">The Collection</h1>
|
categories={categories}
|
||||||
<p className="text-spa-stone max-w-md mx-auto">
|
categoriesLoading={loading}
|
||||||
Browse our full range of premium aroma products — crafted to elevate every space.
|
/>
|
||||||
</p>
|
</div>
|
||||||
|
) : (
|
||||||
|
<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 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 */}
|
{/* Search */}
|
||||||
<div className="max-w-md mx-auto mt-6">
|
<div className="max-w-md mx-auto mt-6">
|
||||||
|
<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
|
||||||
|
type="text"
|
||||||
|
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-pop-black placeholder-spa-stone/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render Search bar below Hero block if Hero is configured */}
|
||||||
|
{heroSections.length > 0 && (
|
||||||
|
<div className="max-w-md mx-auto mt-8 px-6">
|
||||||
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
@@ -124,7 +183,7 @@ export default function ShopPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
|
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
|
||||||
{/* Category Filter Tabs */}
|
{/* Category Filter Tabs */}
|
||||||
@@ -210,6 +269,14 @@ export default function ShopPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import { API_BASE, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
import { Search, MapPin, Package, Loader2, ChevronRight, Clock } from 'lucide-react'
|
import { Search, MapPin, Package, Loader2, ChevronRight, Clock } from 'lucide-react'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
|
||||||
@@ -26,6 +27,23 @@ export default function TrackOrderPage() {
|
|||||||
const [phone, setPhone] = useState('')
|
const [phone, setPhone] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/track-order-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Track Order dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`)
|
fetch(`${API_BASE}/categories`)
|
||||||
@@ -86,20 +104,40 @@ export default function TrackOrderPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-pop-bg">
|
<div className="min-h-screen flex flex-col bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<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">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-4">
|
{heroSections.length > 0 ? (
|
||||||
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mx-auto border border-spa-stone/10">
|
<DynamicPageRenderer
|
||||||
<MapPin className="w-8 h-8 text-spa-forest" />
|
sections={heroSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<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 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>
|
||||||
<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">
|
<div className="grid md:grid-cols-2 gap-12">
|
||||||
|
|
||||||
@@ -234,6 +272,14 @@ export default function TrackOrderPage() {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { Link } from 'react-router-dom'
|
|||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
import { API_BASE, type Category } from '../types'
|
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||||
|
import { API_BASE, type Category, type PageSection } from '../types'
|
||||||
import { useWishlistStore } from '../store/wishlistStore'
|
import { useWishlistStore } from '../store/wishlistStore'
|
||||||
import { Heart } from 'lucide-react'
|
import { Heart } from 'lucide-react'
|
||||||
|
|
||||||
export default function WishlistPage() {
|
export default function WishlistPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const { items } = useWishlistStore()
|
const { items } = useWishlistStore()
|
||||||
|
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/categories`)
|
fetch(`${API_BASE}/categories`)
|
||||||
@@ -18,22 +20,58 @@ export default function WishlistPage() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/pages/wishlist-layout`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Page not found');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && data.sections) {
|
||||||
|
setCmsSections(data.sections);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('Wishlist dynamic layout sections not loaded:', err.message);
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
|
||||||
|
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
|
||||||
|
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-pop-bg">
|
<div className="min-h-screen flex flex-col bg-pop-bg">
|
||||||
|
{announcementSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={announcementSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} />
|
||||||
|
|
||||||
<main className="flex-1 max-w-7xl w-full mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in">
|
<main className="flex-1 max-w-7xl w-full mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center space-y-4">
|
{heroSections.length > 0 ? (
|
||||||
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto border border-rose-100">
|
<DynamicPageRenderer
|
||||||
<Heart className="w-8 h-8 text-rose-500 fill-rose-500" />
|
sections={heroSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto border border-rose-100">
|
||||||
|
<Heart className="w-8 h-8 text-rose-500 fill-rose-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Your Wishlist</h1>
|
||||||
|
<p className="text-spa-stone max-w-lg mx-auto">
|
||||||
|
Items you've loved. Keep them here until you're ready to make them yours.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Your Wishlist</h1>
|
)}
|
||||||
<p className="text-spa-stone max-w-lg mx-auto">
|
|
||||||
Items you've loved. Keep them here until you're ready to make them yours.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="text-center py-24 bg-white shadow-sm border border-spa-stone/10 rounded-3xl max-w-2xl mx-auto space-y-6">
|
<div className="text-center py-24 bg-white shadow-sm border border-spa-stone/10 rounded-3xl max-w-2xl mx-auto space-y-6">
|
||||||
@@ -61,6 +99,14 @@ export default function WishlistPage() {
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{bottomSections.length > 0 && (
|
||||||
|
<DynamicPageRenderer
|
||||||
|
sections={bottomSections}
|
||||||
|
categories={categories}
|
||||||
|
categoriesLoading={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Footer categories={categories} />
|
<Footer categories={categories} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
|
"swiper": "^12.2.0",
|
||||||
"zustand": "^5.0.12",
|
"zustand": "^5.0.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1539,6 +1540,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"swiper": ["swiper@12.2.0", "", {}, "sha512-K8uXsBZU6ME97Ia3xbBge8IRCnR1lOmIILzvY/jGVic7dSTQ530s3uO8RvXbPUtkkXLWIwmZLRPbtDxRWVAFdg=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|||||||
@@ -160,3 +160,104 @@ export interface CartItem {
|
|||||||
variantSize?: string | null
|
variantSize?: string | null
|
||||||
productImage?: string | null
|
productImage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Page {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
isPublished: boolean
|
||||||
|
sections?: PageSection[]
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageSection {
|
||||||
|
id: number
|
||||||
|
pageId: number
|
||||||
|
type: string // e.g. 'HERO' | 'CATEGORIES' | 'PRODUCTS' | 'BANNER' | 'TEXT'
|
||||||
|
order: number
|
||||||
|
payload: any // Specific payload depending on type
|
||||||
|
isActive: boolean
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroSectionPayload {
|
||||||
|
tagline: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
buttonText: string
|
||||||
|
buttonLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoriesSectionPayload {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
categoryIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsSectionPayload {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
type: 'featured' | 'scent' | 'custom'
|
||||||
|
scentId?: number
|
||||||
|
productIds?: number[]
|
||||||
|
limit?: number
|
||||||
|
layout?: 'grid' | 'carousel'
|
||||||
|
enableQuickShop?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerSectionPayload {
|
||||||
|
imageUrl: string
|
||||||
|
linkUrl?: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
buttonText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextSectionPayload {
|
||||||
|
title?: string
|
||||||
|
content: string
|
||||||
|
textAlign?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementBarSectionPayload {
|
||||||
|
slides: string[]
|
||||||
|
backgroundColor?: string
|
||||||
|
textColor?: string
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsletterSectionPayload {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
buttonText: string
|
||||||
|
placeholderText?: string
|
||||||
|
showPrivacyCheckbox: boolean
|
||||||
|
privacyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorialTileSectionPayload {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
imagePosition: 'left' | 'right'
|
||||||
|
buttonText?: string
|
||||||
|
buttonLink?: string
|
||||||
|
backgroundColor?: string
|
||||||
|
textColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorialCarouselSectionPayload {
|
||||||
|
title: string
|
||||||
|
slides: {
|
||||||
|
category: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
buttonText?: string
|
||||||
|
buttonLink?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user