This commit is contained in:
Your Name committed 2026-06-07 17:17:01 +07:00
1 parent d325cd8763
commit c6bf2f70ac
45 files changed
+5064 -207

No files matched your search

+47
View File
@@ -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 รับทราบ พร้อมแนะนำวิธีการทดสอบระบบ
+43
View File
@@ -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 ของหน้าเว็บ
+2
View File
@@ -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 />} />
+1 -2
View File
@@ -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;
@@ -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");
+31
View File
@@ -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
}
+712
View File
@@ -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();
});
+2 -1
View File
@@ -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);
+46 -2
View File
@@ -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())
})
}); });
+17
View File
@@ -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) },
+381
View File
@@ -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;
}
+5
View File
@@ -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;
+219
View File
@@ -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}`);
} }
+1
View File
@@ -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": {
+2
View File
@@ -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>
);
}
+54
View File
@@ -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">&rarr;</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>
);
}
+77
View File
@@ -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">&rarr;</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 &rarr;
</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>
);
}
+31
View File
@@ -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>
);
}
+94 -22
View File
@@ -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 */}
{enableQuickShop && product.variants && product.variants.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2 mb-1">
{product.variants.map((variant) => {
const isSelected = selectedVariant?.id === variant.id;
return (
<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"> <span className="text-rose-500 font-black text-sm md:text-lg">
{formatPrice(product.variants[0]?.price)} {formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
</span> </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"> <span className="text-[10px] md:text-xs font-bold text-spa-forest uppercase tracking-widest flex items-center gap-1 group-hover:translate-x-1 transition-transform">
View Details &rarr; View Details &rarr;
</span> </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>
) );
} }
+55 -1
View File
@@ -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>
+49 -1
View File
@@ -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,6 +12,7 @@ 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)
@@ -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,11 +91,32 @@ 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 */}
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="relative h-64 md:h-80 overflow-hidden"> <div className="relative h-64 md:h-80 overflow-hidden">
<img <img
src={getCategoryImage(category?.name ?? '')} src={getCategoryImage(category?.name ?? '')}
@@ -112,6 +151,7 @@ export default function CategoryPage() {
</div> </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>
) )
+50 -2
View File
@@ -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 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</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> <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> <h1 className="text-5xl font-bold">Contact Us</h1>
<p className="text-slate-400 max-w-lg mx-auto"> <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. Have a question, custom request, or partnership idea? We'd love to hear from you.
</p> </p>
</div> </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>
) )
+53 -1
View File
@@ -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,11 +84,31 @@ 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]">
{/* Header */}
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4 mb-16"> <div className="text-center space-y-4 mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-pop-black"> <h1 className="text-4xl md:text-5xl font-bold text-pop-black">
Exclusive Offers Exclusive Offers
@@ -74,6 +117,7 @@ export default function CouponsPage() {
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย! คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
</p> </p>
</div> </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>
) )
+93
View File
@@ -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>
);
}
+50 -2
View File
@@ -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 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</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> <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> <h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
<p className="text-slate-400 max-w-lg mx-auto"> <p className="text-slate-400 max-w-lg mx-auto">
Everything you need to know about HomAroma products, shipping, and care. Everything you need to know about HomAroma products, shipping, and care.
</p> </p>
</div> </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>
) )
+31 -163
View File
@@ -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 */}
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-night-900">
<div className="absolute inset-0 z-0">
<img
src={hero?.imageUrl || "/images/hero.png"}
className="w-full h-full object-cover scale-[1.02]"
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 className="relative z-10 text-center space-y-6 px-8 animate-fade-in max-w-4xl w-full mx-auto mt-16 md:mt-0">
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
{hero?.tagline || "Vibrant Elegance Collection"}
</p>
<h1 className="text-5xl md:text-7xl 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">&rarr;</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 ? ( {loading ? (
<div className="grid grid-cols-2 md:grid-cols-5 gap-6"> <div className="min-h-[60vh] flex items-center justify-center">
{Array.from({ length: 5 }).map((_, i) => ( <div className="w-12 h-12 border-4 border-pop-pink border-t-transparent rounded-full animate-spin"></div>
<div key={i} className="h-80 brutal-box bg-gray-50 animate-pulse" />
))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-5 gap-6"> <DynamicPageRenderer
{categories.map((cat) => ( sections={sections}
<Link categories={categories}
key={cat.id} categoriesLoading={categoriesLoading}
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 &rarr;
</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>
+28 -3
View File
@@ -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>
) )
+38 -1
View File
@@ -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 */}
+50 -2
View File
@@ -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 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</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> <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> <h1 className="text-5xl font-bold">Scent Guide</h1>
<p className="text-slate-400 max-w-lg mx-auto"> <p className="text-slate-400 max-w-lg mx-auto">
Explore our collection of articles, guides, and sensory knowledge. Explore our collection of articles, guides, and sensory knowledge.
</p> </p>
</div> </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>
) )
+49 -1
View File
@@ -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,11 +64,32 @@ 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 */}
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="pt-32 pb-16 px-6 bg-spa-light border-b border-spa-stone/10"> <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"> <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"> <p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
@@ -94,6 +133,7 @@ export default function SearchPage() {
</div> </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>
) )
+68 -1
View File
@@ -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,11 +114,32 @@ 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 */}
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-spa-light"> <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"> <p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
All Products All Products
@@ -125,6 +165,25 @@ export default function ShopPage() {
</div> </div>
</div> </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">
<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 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>
) )
+47 -1
View File
@@ -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,13 +104,32 @@ 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 */}
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4"> <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"> <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" /> <MapPin className="w-8 h-8 text-spa-forest" />
@@ -100,6 +137,7 @@ export default function TrackOrderPage() {
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Track Your Status</h1> <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> <p className="text-spa-stone">Enter your order details below to check shipping status or upload payment slips.</p>
</div> </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>
) )
+47 -1
View File
@@ -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,13 +20,48 @@ 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 */}
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4"> <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"> <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" /> <Heart className="w-8 h-8 text-rose-500 fill-rose-500" />
@@ -34,6 +71,7 @@ export default function WishlistPage() {
Items you've loved. Keep them here until you're ready to make them yours. Items you've loved. Keep them here until you're ready to make them yours.
</p> </p>
</div> </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>
) )
+3
View File
@@ -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=="],
+101
View File
@@ -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
}[]
}