diff --git a/.agents/skills/cms-content-manager/SKILL.md b/.agents/skills/cms-content-manager/SKILL.md index f196a7f..e64c37e 100644 --- a/.agents/skills/cms-content-manager/SKILL.md +++ b/.agents/skills/cms-content-manager/SKILL.md @@ -6,26 +6,16 @@ description: CMS Content Manager / MCP Tester Agent. รับผิดชอบ # CMS Content Manager (MCP Tester) ## บทบาทและหน้าที่ (Role & Responsibilities) -คุณคือ **CMS Content Manager** และ **MCP Tester** ประจำโปรเจกต์ HomAroma +คุณคือ **MCP UX/DX Tester** และ **CMS Content Manager** ประจำโปรเจกต์ HomAroma -หน้าที่หลักของคุณคือ: -1. **Data Entry & Content Management:** บริหารจัดการเนื้อหาต่างๆ บนเว็บไซต์ เช่น หน้า Dynamic Pages (About, FAQ, Contact) และ Menus ต่างๆ ผ่านการใช้งานเครื่องมือ MCP (Model Context Protocol) -2. **Production Testing:** ทดสอบการส่งข้อมูลไปยังเซิร์ฟเวอร์จริง (Production) ผ่าน MCP Tools เพื่อให้แน่ใจว่าระบบตรวจสอบความถูกต้อง (Validation) ทำงานได้อย่างถูกต้อง +หน้าที่หลักของคุณคือการ **"ทดสอบและพัฒนาระบบเครื่องมือ (Tool Development)"**: +1. **Tool UX/DX Auditing (Priority 1):** รับบทบาทเป็นผู้ใช้งานคนแรก (First User) เพื่อทดสอบการเรียกใช้งาน MCP Tools ที่สร้างขึ้นใหม่ หน้าที่ของคุณคือการจับผิดและประเมินว่า Tool นั้น "ใช้งานง่ายสมเหตุสมผลหรือไม่" หากพบข้อจำกัด, Validation ที่จู้จี้เกินไป, หรือ Error ที่ไม่สื่อความหมาย คุณต้องส่ง Feedback กลับไปให้ทีม Backend แก้ไขทันที +2. **Blackbox Testing:** ทดสอบการส่งข้อมูลจริงเข้าสู่ระบบผ่าน MCP Tools เพื่อยืนยันว่าข้อมูลถูกบันทึกและตอบกลับอย่างถูกต้อง (Data Integrity) โดยไม่ต้องสนใจว่าโค้ดข้างในเขียนมาอย่างไร +3. **Data Entry (Secondary):** บริหารจัดการและแปลเนื้อหาต่างๆ บน CMS เมื่อ Tool นั้นผ่านการรับรองว่าใช้งานได้ดีแล้ว ## กฎเหล็ก (Strict Sandbox Rules) คุณถูกจำกัดสิทธิ์ขั้นสูงสุดเพื่อให้ทำหน้าที่ Data Entry เท่านั้น: - **[ห้าม]** เขียนโค้ดหรือแก้ไขซอร์สโค้ด (Source Code) ในโปรเจกต์นี้โดยเด็ดขาด - **[ห้าม]** ใช้คำสั่ง Terminal/Bash เพื่อแก้ไขไฟล์ หรือเขียนสคริปต์ขึ้นมาใหม่เพื่ออ้อมกฎ - **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP -- **[ต้องทำ]** เมื่อเจอปัญหา Error จาก API (เช่น Validation Error) ให้หยุดทำงานแล้วรายงาน Error กลับไปที่ Main Agent หรือ User ทันที ห้ามคิดหาวิธีแก้โค้ดเอง - -## กฎการจัดการ Product Variants (Variant Mapping Rules) -- เมื่อใช้งาน Tool ที่เกี่ยวข้องกับการสร้างหรืออัปเดต Variant (เช่น `sync_product_variants`, `create_product`, `bulk_create_products`) **ห้าม**ใช้คำแปลภาษาอื่นในการจับคู่ `option1`, `option2`, หรือ `option3` -- คุณ **ต้อง** ใช้ค่า Value จากภาษาหลัก (`th`) ที่ระบุไว้ใน `options.values` เสมอ เพื่อให้ Backend สามารถจับคู่ Variant กับ Option ได้ถูกต้อง - -## เครื่องมือที่แนะนำ (Tools) -- ใช้ `call_mcp_tool` ร่วมกับเซิร์ฟเวอร์ `homaroma_mcp` ในการจัดการข้อมูล - - `create_dynamic_page`: ใช้สร้างหน้าใหม่ - - `add_page_section`: ใช้เพิ่มกล่องข้อมูลเข้าไปในหน้า - - `update_page_section`: ใช้แก้ไขข้อมูล - - `delete_page_section`: ใช้ลบข้อมูล +- **[ต้องทำ] (UX/DX AUDIT PROTOCOL):** ห้ามคุณดัดแปลงข้อมูล Input (Silent Workarounds) ของตัวเองเพื่อให้ผ่าน Validation เด็ดขาด! หากพบ Error ในการเรียกใช้งาน Tool ครั้งแรก (ตามเจตนาธรรมชาติของคุณ) ให้ **"หยุดทำงาน"** ทันที แล้วนำ Error นั้นกลับมารายงานให้ User ทราบครบทุกตัวอักษร เป้าหมายหลักของคุณคือการ **หาข้อบกพร่อง (Bad UX/DX) ของ Tool** ไม่ใช่การทำ Task ให้เสร็จรอดไปได้ diff --git a/AGENTS.md b/AGENTS.md index 0a72aa3..962c2e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,3 +42,4 @@ Every final handoff should summarize: - When instructed to test, use, or audit MCP tools, you MUST operate in **Strict Blackbox Mode**. - **FORBIDDEN:** You are strictly prohibited from using `view_file`, `grep_search`, `run_command`, or reading any backend source code (e.g., `schema.prisma`, `types`) to find answers or discover capabilities. - **ESCALATE LIMITATIONS:** If the MCP server fails to provide the necessary tools, metadata, or schema descriptions for you to complete a task, you MUST immediately report this architectural flaw to the user. Do not use local file system tools as a silent workaround. +- **NO SILENT WORKAROUNDS (UX/DX AUDIT):** Do NOT fix errors yourself under any circumstances. If you encounter an error on your first natural attempt to use a tool, you MUST report back immediately with the exact error. Your goal is to find bad Developer Experience (UX/DX) and improve the tool, NOT to just succeed at the task at all costs. diff --git a/apps/admin/src/components/page-builder/EditorialCarouselForm.tsx b/apps/admin/src/components/page-builder/EditorialCarouselForm.tsx index a5b25a4..e0dc3f3 100644 --- a/apps/admin/src/components/page-builder/EditorialCarouselForm.tsx +++ b/apps/admin/src/components/page-builder/EditorialCarouselForm.tsx @@ -40,6 +40,55 @@ export default function EditorialCarouselForm({

Use [brackets] for golden accent serif text.

+
+
+ +
+ setEditingSection({ + ...editingSection, + payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } + })} + className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10" + /> + setEditingSection({ + ...editingSection, + payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } + })} + className="admin-input flex-1 font-mono text-xs" + /> +
+
+
+ +
+ setEditingSection({ + ...editingSection, + payload: { ...(editingSection.payload || {}), textColor: e.target.value } + })} + className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10" + /> + setEditingSection({ + ...editingSection, + payload: { ...(editingSection.payload || {}), textColor: e.target.value } + })} + className="admin-input flex-1 font-mono text-xs" + /> +
+
+
+
diff --git a/apps/admin/src/components/page-builder/EditorialTileForm.tsx b/apps/admin/src/components/page-builder/EditorialTileForm.tsx index d084d15..72d4f5e 100644 --- a/apps/admin/src/components/page-builder/EditorialTileForm.tsx +++ b/apps/admin/src/components/page-builder/EditorialTileForm.tsx @@ -81,7 +81,7 @@ export default function EditorialTileForm({
setEditingSection({ @@ -89,13 +89,13 @@ export default function EditorialTileForm({ payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } }) } - className="admin-input w-full" + className="admin-input w-full h-10 p-1 cursor-pointer" />
setEditingSection({ @@ -103,7 +103,7 @@ export default function EditorialTileForm({ payload: { ...(editingSection.payload || {}), textColor: e.target.value } }) } - className="admin-input w-full" + className="admin-input w-full h-10 p-1 cursor-pointer" />
diff --git a/apps/admin/src/components/page-builder/ProductCarouselForm.tsx b/apps/admin/src/components/page-builder/ProductCarouselForm.tsx index 5cfe9f3..e039f7e 100644 --- a/apps/admin/src/components/page-builder/ProductCarouselForm.tsx +++ b/apps/admin/src/components/page-builder/ProductCarouselForm.tsx @@ -1,5 +1,17 @@ -import React from 'react'; +import React, { useState } from 'react'; import { type PageSection } from '@homaroma/types'; +import ProductPickerModal from './ProductPickerModal'; +import { getAdminDisplayString } from '../../utils/localization'; + +interface Brand { + id: number; + name: string; +} + +interface Category { + id: number; + name: any; +} interface ProductCarouselFormProps { editingSection: Partial; @@ -7,6 +19,8 @@ interface ProductCarouselFormProps { setEditingSection: React.Dispatch | null>>; getLocalizedValue: (payload: any, field: string) => string; updateLocalizedValue: (field: string, value: string) => void; + brands: Brand[]; + categories?: Category[]; } export default function ProductCarouselForm({ @@ -14,7 +28,11 @@ export default function ProductCarouselForm({ setEditingSection, getLocalizedValue, updateLocalizedValue, + brands, + categories = [], }: ProductCarouselFormProps) { + const [isPickerOpen, setIsPickerOpen] = useState(false); + const updateFlag = (field: 'showPrice' | 'showAddToCart', value: boolean) => { setEditingSection({ ...editingSection, @@ -48,19 +66,27 @@ export default function ProductCarouselForm({
- - Products Source type +
@@ -102,9 +128,89 @@ export default function ProductCarouselForm({
-

- If your backend supports collection lookup by ID, place that value here. Otherwise leave it blank and use the default product list behavior. -

+ {editingSection.payload?.type === 'featured' && ( +
+ + +
+ )} + + {editingSection.payload?.type === 'brand' && ( +
+ + +
+ )} + + {editingSection.payload?.type === 'custom' && ( +
+ +
+ + +
+ {Array.isArray(editingSection.payload?.productIds) && editingSection.payload.productIds.length > 0 && ( +
+ IDs: {editingSection.payload.productIds.join(', ')} +
+ )} +
+ )} + + {isPickerOpen && ( + { + setEditingSection((prev) => prev ? { + ...prev, + payload: { ...(prev.payload || {}), productIds: ids } + } : null); + }} + onClose={() => setIsPickerOpen(false)} + /> + )}
); } diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index e7c492b..002cff9 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -1144,6 +1144,8 @@ export default function PageBuilderPage() { editingLang={editingLang} getLocalizedValue={getLocalizedValue} updateLocalizedValue={updateLocalizedValue} + brands={brands} + categories={categories} /> )} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 905aea2..1770690 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -56,6 +56,15 @@ const allowLanDevOrigin = (request: Request) => { }; const app = new Elysia() + .onRequest(({ request }) => { + console.log(`\n➡️ [${request.method}] ${request.url}`); + }) + .onAfterHandle(({ request, set }) => { + console.log(`✅ [${request.method}] ${request.url} → ${set.status ?? 200}`); + }) + .onError(({ request, error, set }) => { + console.error(`❌ [${request.method}] ${request.url} → ${set.status ?? 500}`, error?.message ?? error); + }) .onParse(async ({ request }) => { console.log('[onParse] Incoming request URL:', request.url); if (request.url.includes('/api/line/webhook')) { diff --git a/apps/backend/src/lib/prisma.ts b/apps/backend/src/lib/prisma.ts index c344f78..e2883ba 100644 --- a/apps/backend/src/lib/prisma.ts +++ b/apps/backend/src/lib/prisma.ts @@ -10,6 +10,13 @@ const connectionString = process.env.DATABASE_URL!; const pool = new Pool({ connectionString }); const adapter = new PrismaPg(pool); -const prisma = new PrismaClient({ adapter }); +const prisma = new PrismaClient({ + adapter, + log: [ + { emit: 'stdout', level: 'query' }, + { emit: 'stdout', level: 'error' }, + { emit: 'stdout', level: 'warn' }, + ], +}); export default prisma; diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 70ffe5a..b575c63 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -18,28 +18,49 @@ import { ProductCarouselSectionPayloadSchema, AccordionSectionPayloadSchema, ContactInfoSectionPayloadSchema, - ContactFormSectionPayloadSchema + ContactFormSectionPayloadSchema, } from "@homaroma/types"; import { z } from "zod"; import { adminOnly } from "../middleware/auth"; const RESERVED_SLUGS = [ - "shop", "category", "products", "cart", "checkout", "account", "search", - "login", "admin", "wishlist", "track", "coupons", "scent-guide", - "profile", "article", "pages", "orders", "customers", - "settings", "menus", "auth", "api" + "shop", + "category", + "products", + "cart", + "checkout", + "account", + "search", + "login", + "admin", + "wishlist", + "track", + "coupons", + "scent-guide", + "profile", + "article", + "pages", + "orders", + "customers", + "settings", + "menus", + "auth", + "api", ]; /** Resolve category name — mirrors the same helper in categories.ts */ function resolveCatName(cat: any, lang: string): any { - if (lang === 'all') { + if (lang === "all") { const nameObj: any = {}; - cat.translations?.forEach((tr: any) => { nameObj[tr.langCode] = tr.name; }); + cat.translations?.forEach((tr: any) => { + nameObj[tr.langCode] = tr.name; + }); return nameObj; } - const trans = cat.translations?.find((t: any) => t.langCode === lang) - || cat.translations?.find((t: any) => t.langCode === 'th') - || cat.translations?.[0]; + const trans = + cat.translations?.find((t: any) => t.langCode === lang) || + cat.translations?.find((t: any) => t.langCode === "th") || + cat.translations?.[0]; return trans?.name ?? cat.name; } @@ -48,22 +69,23 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) return await prisma.page.findMany({ include: { sections: { - orderBy: { order: "asc" } - } - } + orderBy: { order: "asc" }, + }, + }, }); }) .get("/:slug", async ({ params: { slug }, query, set }) => { - const includeInactive = String((query as any)?.includeInactive || "").toLowerCase() === "true"; - const lang = (query as any)?.lang || 'th'; + const includeInactive = + String((query as any)?.includeInactive || "").toLowerCase() === "true"; + const lang = (query as any)?.lang || "th"; const page = await prisma.page.findUnique({ where: { slug }, include: { sections: { where: includeInactive ? undefined : { isActive: true }, - orderBy: { order: "asc" } - } - } + orderBy: { order: "asc" }, + }, + }, }); if (!page) { set.status = 404; @@ -71,285 +93,156 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) } 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: { - include: { translations: true }, - }, - brand: true, - translations: 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: Number(payload.categoryId) || 1 }, - include: productInclude, - take: limit - }); - } else if ((payload.type === 'scent' || payload.type === 'brand') && (payload.scentId || payload.brandId)) { - products = await prisma.product.findMany({ - where: { brandId: Number(payload.brandId || 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.map((p: any) => { - if (lang === 'all') { - const nameObj: any = {}; - const descObj: any = {}; - const detailsObj: any = {}; - p.translations?.forEach((t: any) => { - nameObj[t.langCode] = t.name; - descObj[t.langCode] = t.description; - detailsObj[t.langCode] = t.details; - }); - return { ...p, name: nameObj, description: descObj, details: detailsObj }; - } else { - const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0]; - return { ...p, name: trans?.name || p.brand?.name || '', description: trans?.description, details: trans?.details }; - } - }); - } else if (section.type === 'PRODUCT_CAROUSEL' && !Array.isArray(populatedPayload.products)) { - let products: any[] = []; - const limit = payload.limit || 3; - - const productInclude = { - category: { - include: { translations: true }, - }, - brand: true, - translations: true, - variants: { - include: { - bomItems: { include: { rawMaterial: true } } - }, - orderBy: { price: "asc" as const } - }, - images: { - orderBy: { order: "asc" as const } - } - }; - - if (payload.type === 'featured' || payload.collectionId === 'featured') { - products = await prisma.product.findMany({ - where: { categoryId: Number(payload.categoryId) || 1 }, - include: productInclude, - take: limit - }); - } else if ((payload.type === 'scent' || payload.type === 'brand') && (payload.scentId || payload.brandId)) { - products = await prisma.product.findMany({ - where: { brandId: Number(payload.brandId || payload.scentId) }, - include: productInclude, - take: limit - }); - } else if (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)); - } else { - products = await prisma.product.findMany({ - include: productInclude, - take: limit - }); - } - - populatedPayload.products = products.map((p: any) => { - if (lang === 'all') { - const nameObj: any = {}; - const descObj: any = {}; - const detailsObj: any = {}; - p.translations?.forEach((t: any) => { - nameObj[t.langCode] = t.name; - descObj[t.langCode] = t.description; - detailsObj[t.langCode] = t.details; - }); - return { ...p, name: nameObj, description: descObj, details: detailsObj }; - } - const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0]; - return { ...p, name: trans?.name || p.brand?.name || '', description: trans?.description, details: trans?.details }; - }); - } 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: { - translations: true, - _count: { - select: { products: true } - } - } - }); - categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); - } else { - categories = await prisma.category.findMany({ - include: { - translations: true, - _count: { - select: { products: true } - } - } - }); - } - // Resolve name for each category using the lang param (supports 'all') - populatedPayload.categories = categories.map((cat: any) => ({ - ...cat, - name: resolveCatName(cat, lang), - })); - } - - return { - ...section, - payload: populatedPayload - }; - }) + page.sections.map(async (section: any) => await hydrateSectionPayload(section, lang)) ); return { ...page, - sections: populatedSections + sections: populatedSections, }; }) .get("/:slug/sections", async ({ params: { slug }, set }) => { try { return await prisma.pageSection.findMany({ where: { pageId: Number(slug) }, - orderBy: { order: "asc" } + orderBy: { order: "asc" }, }); } catch (err) { set.status = 500; return { error: "Failed to fetch page sections" }; } }) - .post("/newsletter", async ({ body, set }) => { + .get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => { + const lang = (query as any)?.lang || "th"; 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 } + const section = await prisma.pageSection.findUnique({ + where: { id: Number(sectionId) }, }); - return { success: true, data: sub }; + if (!section) { + set.status = 404; + return { error: "Section not found" }; + } + return await hydrateSectionPayload(section, lang); } catch (err) { - console.error("[Newsletter] Error:", err); set.status = 500; - return { error: "Failed to subscribe" }; + return { error: "Failed to fetch page section" }; } - }, { - body: t.Object({ - email: t.String() - }) }) + .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(), + }), + }, + ) .use(adminOnly) - .post("/", async ({ body, set }) => { - const data = body as any; - const operation = data?.id ? "update" : "create"; - try { - const { id, slug, title, isPublished, status, metadata } = data; - - if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) { - set.status = 400; + .post( + "/", + async ({ body, set }) => { + const data = body as any; + const operation = data?.id ? "update" : "create"; + try { + const { id, slug, title, isPublished, status, metadata } = data; + + if ( + typeof slug === "string" && + RESERVED_SLUGS.includes(slug.toLowerCase()) + ) { + set.status = 400; + return { + success: false, + operation, + pageId: id ? Number(id) : null, + slug, + title, + error: "Reserved slug cannot be used", + diagnostic: + "The requested slug is reserved and cannot be used for dynamic pages.", + }; + } + + let finalIsPublished = true; + if (status !== undefined) { + finalIsPublished = status === "published"; + } else if (isPublished !== undefined) { + finalIsPublished = isPublished; + } + + const page = id + ? await prisma.page.update({ + where: { id: Number(id) }, + data: { slug, title, isPublished: finalIsPublished }, + }) + : await prisma.page.create({ + data: { slug, title, isPublished: finalIsPublished }, + }); + + return { + success: true, + operation, + pageId: page.id, + slug: page.slug, + title: page.title, + isPublished: page.isPublished, + page, + diagnostic: + operation === "create" + ? "Page created successfully." + : "Page updated successfully.", + }; + } catch (err) { + console.error("[Pages] Error:", err); + set.status = 500; return { success: false, operation, - pageId: id ? Number(id) : null, - slug, - title, - error: "Reserved slug cannot be used", - diagnostic: "The requested slug is reserved and cannot be used for dynamic pages." + pageId: data?.id ? Number(data.id) : null, + slug: data?.slug, + title: data?.title, + error: "Failed to save page", + diagnostic: + err instanceof Error + ? err.message + : "Unexpected error while saving page.", }; } - - let finalIsPublished = true; - if (status !== undefined) { - finalIsPublished = status === "published"; - } else if (isPublished !== undefined) { - finalIsPublished = isPublished; - } - - const page = id - ? await prisma.page.update({ - where: { id: Number(id) }, - data: { slug, title, isPublished: finalIsPublished } - }) - : await prisma.page.create({ - data: { slug, title, isPublished: finalIsPublished } - }); - - return { - success: true, - operation, - pageId: page.id, - slug: page.slug, - title: page.title, - isPublished: page.isPublished, - page, - diagnostic: operation === "create" - ? "Page created successfully." - : "Page updated successfully." - }; - } catch (err) { - console.error("[Pages] Error:", err); - set.status = 500; - return { - success: false, - operation, - pageId: data?.id ? Number(data.id) : null, - slug: data?.slug, - title: data?.title, - error: "Failed to save page", - diagnostic: err instanceof Error ? err.message : "Unexpected error while saving page." - }; - } - }, { - body: t.Object({ - id: t.Optional(t.Number()), - slug: t.String(), - title: t.String(), - isPublished: t.Optional(t.Boolean()), - status: t.Optional(t.String()), - metadata: t.Optional(t.Any()) - }) - }) + }, + { + body: t.Object({ + id: t.Optional(t.Number()), + slug: t.String(), + title: t.String(), + isPublished: t.Optional(t.Boolean()), + status: t.Optional(t.String()), + metadata: t.Optional(t.Any()), + }), + }, + ) .delete("/:slug", async ({ params: { slug }, set }) => { try { await prisma.page.delete({ - where: { id: Number(slug) } + where: { id: Number(slug) }, }); return { success: true }; } catch (err) { @@ -366,25 +259,30 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) const payload = body as any; const results: any[] = []; - const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko']; + const LANGS = ["th", "en", "ja", "zh", "vi", "ko"]; for (const lang of LANGS) { - const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang]; + const title = + typeof payload.title === "string" + ? lang === "th" + ? payload.title + : null + : payload.title?.[lang]; if (!title) continue; const existing = await prisma.pageTranslation.findUnique({ - where: { pageId_langCode: { pageId: id, langCode: lang } } + where: { pageId_langCode: { pageId: id, langCode: lang } }, }); if (existing) { const updated = await prisma.pageTranslation.update({ where: { id: existing.id }, - data: { title } + data: { title }, }); results.push(updated); } else { const created = await prisma.pageTranslation.create({ - data: { pageId: id, langCode: lang, title } + data: { pageId: id, langCode: lang, title }, }); results.push(created); } @@ -392,128 +290,162 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) const updated = await prisma.page.findUnique({ where: { id }, - include: { translations: true } + include: { translations: true }, }); return { success: true, translations: results, page: updated }; }) - .post("/:slug/sections", async ({ params: { slug }, body, set }) => { - try { - const pageId = Number(slug); - const { type, order, payload, isActive } = body as any; + .post( + "/:slug/sections", + async ({ params: { slug }, body, set }) => { + try { + const pageId = Number(slug); + const { type, order, payload, isActive } = body as any; - const validationResult = validateSectionPayload(type, payload); - if (!validationResult.success) { - set.status = 400; - return { error: "Validation failed", details: validationResult.issues }; - } - - let finalOrder = order; - if (finalOrder === undefined) { - const lastSection = await prisma.pageSection.findFirst({ - where: { pageId }, - orderBy: { order: "desc" } - }); - finalOrder = lastSection ? lastSection.order + 1 : 1; - } - - return await prisma.pageSection.create({ - data: { - pageId, - type: type.toUpperCase(), - order: finalOrder, - 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.Optional(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) { - let validationType = type; - if (!validationType) { - const existingSection = await prisma.pageSection.findUnique({ where: { id } }); - if (!existingSection) { - set.status = 404; - return { error: "Section not found" }; - } - validationType = existingSection.type; - } - const validationResult = validateSectionPayload(validationType, payload); + const validationResult = validateSectionPayload(type, payload); if (!validationResult.success) { set.status = 400; - return { error: "Validation failed", details: validationResult.issues }; + return { + error: "Validation failed", + details: validationResult.issues, + }; } + + let finalOrder = order; + if (finalOrder === undefined) { + const lastSection = await prisma.pageSection.findFirst({ + where: { pageId }, + orderBy: { order: "desc" }, + }); + finalOrder = lastSection ? lastSection.order + 1 : 1; + } + + return await prisma.pageSection.create({ + data: { + pageId, + type: type.toUpperCase(), + order: finalOrder, + payload: validationResult.data, + 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.Optional(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, isActive, payload: explicitPayload, ...rest } = body as any; - return await prisma.pageSection.update({ - where: { id }, - data: { - type: type ? type.toUpperCase() : undefined, - order, - payload, - isActive + console.log(`\n📦 [PUT /sections/${sectionId}] body received:`, JSON.stringify(body, null, 2)); + + // MCP tools send fields flat at root level (not nested under "payload" key) + // Normalize: if no explicit payload key, use remaining root-level fields as payload + const payload = explicitPayload ?? (Object.keys(rest).length > 0 ? rest : undefined); + + let finalPayload = payload; + if (payload) { + let validationType = type; + if (!validationType) { + const existingSection = await prisma.pageSection.findUnique({ + where: { id }, + }); + if (!existingSection) { + set.status = 404; + return { error: "Section not found" }; + } + validationType = existingSection.type; + } + const validationResult = validateSectionPayload( + validationType, + payload, + ); + if (!validationResult.success) { + console.log(`\n❌ [PUT /sections/${sectionId}] Validation FAILED:`, JSON.stringify(validationResult.issues, null, 2)); + set.status = 400; + return { + error: "Validation failed", + details: validationResult.issues, + }; + } + finalPayload = validationResult.data; + console.log(`\n✅ [PUT /sections/${sectionId}] Validation passed. finalPayload:`, JSON.stringify(finalPayload, null, 2)); } - }); - } 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() - })) - }) - }) + console.log(`\n💾 [PUT /sections/${sectionId}] Prisma update with:`, JSON.stringify({ type, order, payload: finalPayload, isActive }, null, 2)); + + return await prisma.pageSection.update({ + where: { id }, + data: { + type: type ? type.toUpperCase() : undefined, + order, + payload: finalPayload, + isActive, + }, + }); + } catch (err) { + console.error("[PageSections] Update Error:", err); + set.status = 500; + return { error: "Failed to update section" }; + } + }, + { + body: t.Any(), + }, + ) + .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(), + }), + ), + }), + }, + ) .delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => { try { await prisma.pageSection.delete({ - where: { id: Number(sectionId) } + where: { id: Number(sectionId) }, }); return { success: true }; } catch (err) { @@ -522,38 +454,215 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) } }); -function validateSectionPayload(type: string, payload: any): { success: boolean; issues?: z.ZodIssue[] } { - if (!payload || typeof payload !== 'object') { - return { success: false, issues: [{ code: 'custom', message: "Payload must be a JSON object", path: [] }] as z.ZodIssue[] }; +function validateSectionPayload( + type: string, + payload: any, +): { success: boolean; issues?: z.ZodIssue[]; data?: any } { + if (!payload || typeof payload !== "object") { + return { + success: false, + issues: [ + { code: "custom", message: "Payload must be a JSON object", path: [] }, + ] as z.ZodIssue[], + }; } let schema: z.ZodTypeAny; switch (type.toUpperCase()) { - case 'HERO': schema = HeroSectionPayloadSchema; break; - case 'CATEGORIES': schema = CategoriesSectionPayloadSchema; break; - case 'PRODUCTS': schema = ProductsSectionPayloadSchema; break; - case 'BANNER': schema = BannerSectionPayloadSchema; break; - case 'TEXT': schema = TextSectionPayloadSchema; break; - case 'ANNOUNCEMENT_BAR': schema = AnnouncementBarSectionPayloadSchema; break; - case 'NEWSLETTER': schema = NewsletterSectionPayloadSchema; break; - case 'EDITORIAL_TILE': schema = EditorialTileSectionPayloadSchema; break; - case 'EDITORIAL_CAROUSEL': schema = EditorialCarouselSectionPayloadSchema; break; - case 'HERO_ARTICLE': schema = HeroArticleSectionPayloadSchema; break; - case 'RICH_TEXT': schema = RichTextSectionPayloadSchema; break; - case 'SCENT_NOTES_BREAKDOWN': schema = ScentNotesBreakdownSectionPayloadSchema; break; - case 'QUOTE_CALLOUT': schema = QuoteCalloutSectionPayloadSchema; break; - case 'PRODUCT_CAROUSEL': schema = ProductCarouselSectionPayloadSchema; break; - case 'ACCORDION': schema = AccordionSectionPayloadSchema; break; - case 'CONTACT_INFO': schema = ContactInfoSectionPayloadSchema; break; - case 'CONTACT_FORM': schema = ContactFormSectionPayloadSchema; break; + case "HERO": + schema = HeroSectionPayloadSchema; + break; + case "CATEGORIES": + schema = CategoriesSectionPayloadSchema; + break; + case "PRODUCTS": + schema = ProductsSectionPayloadSchema; + break; + case "BANNER": + schema = BannerSectionPayloadSchema; + break; + case "TEXT": + schema = TextSectionPayloadSchema; + break; + case "ANNOUNCEMENT_BAR": + schema = AnnouncementBarSectionPayloadSchema; + break; + case "NEWSLETTER": + schema = NewsletterSectionPayloadSchema; + break; + case "EDITORIAL_TILE": + schema = EditorialTileSectionPayloadSchema; + break; + case "EDITORIAL_CAROUSEL": + schema = EditorialCarouselSectionPayloadSchema; + break; + case "HERO_ARTICLE": + schema = HeroArticleSectionPayloadSchema; + break; + case "RICH_TEXT": + schema = RichTextSectionPayloadSchema; + break; + case "SCENT_NOTES_BREAKDOWN": + schema = ScentNotesBreakdownSectionPayloadSchema; + break; + case "QUOTE_CALLOUT": + schema = QuoteCalloutSectionPayloadSchema; + break; + case "PRODUCT_CAROUSEL": + schema = ProductCarouselSectionPayloadSchema; + break; + case "ACCORDION": + schema = AccordionSectionPayloadSchema; + break; + case "CONTACT_INFO": + schema = ContactInfoSectionPayloadSchema; + break; + case "CONTACT_FORM": + schema = ContactFormSectionPayloadSchema; + break; default: - return { success: false, issues: [{ code: 'custom', message: `Unknown section type: ${type}`, path: [] }] as z.ZodIssue[] }; + return { + success: false, + issues: [ + { + code: "custom", + message: `Unknown section type: ${type}`, + path: [], + }, + ] as z.ZodIssue[], + }; } const res = schema.safeParse(payload); if (!res.success) { return { success: false, issues: res.error.issues }; } - return { success: true }; -} \ No newline at end of file + return { success: true, data: res.data }; +} +async function resolveProductsForSection(payload: any, lang: string) { + let products: any[] = []; + const limit = payload.limit || 3; + + const productInclude = { + category: { + include: { translations: true }, + }, + brand: true, + translations: 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: Number(payload.categoryId) || 1 }, + include: productInclude, + take: limit, + }); + } else if ( + (payload.type === "scent" || payload.type === "brand") && + (payload.scentId || payload.brandId) + ) { + products = await prisma.product.findMany({ + where: { brandId: Number(payload.brandId || payload.scentId) }, + include: productInclude, + take: limit, + }); + } else if (payload.type === "custom" && Array.isArray(payload.productIds)) { + const ids = payload.productIds.map(Number); + if (ids.length > 0) { + products = await prisma.product.findMany({ + where: { id: { in: ids } }, + include: productInclude, + }); + products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + } + } else { + products = await prisma.product.findMany({ + include: productInclude, + take: limit, + }); + } + + return products.map((p: any) => { + if (lang === "all") { + const nameObj: any = {}; + const descObj: any = {}; + const detailsObj: any = {}; + p.translations?.forEach((t: any) => { + nameObj[t.langCode] = t.name; + descObj[t.langCode] = t.description; + detailsObj[t.langCode] = t.details; + }); + return { ...p, name: nameObj, description: descObj, details: detailsObj }; + } else { + const trans = + p.translations?.find((t: any) => t.langCode === lang) || + p.translations?.find((t: any) => t.langCode === "th") || + p.translations?.[0]; + return { + ...p, + name: trans?.name || p.brand?.name || "", + description: trans?.description, + details: trans?.details, + }; + } + }); +} + +export async function hydrateSectionPayload(section: any, lang: string) { + const payload = section.payload as any; + if (!payload) return section; + + const populatedPayload = { ...payload }; + + if ( + section.type === "PRODUCTS" || + (section.type === "PRODUCT_CAROUSEL" && + !Array.isArray(populatedPayload.products)) + ) { + populatedPayload.products = await resolveProductsForSection(payload, lang); + } 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: { + translations: true, + _count: { + select: { products: true }, + }, + }, + }); + categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + } else { + categories = await prisma.category.findMany({ + include: { + translations: true, + _count: { + select: { products: true }, + }, + }, + }); + } + // Resolve name for each category using the lang param (supports 'all') + populatedPayload.categories = categories.map((cat: any) => ({ + ...cat, + name: resolveCatName(cat, lang), + })); + } + + return { + ...section, + payload: populatedPayload, + }; +} diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index 188b217..a21e83d 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -38,6 +38,23 @@ const SECTION_TOOL_CONFIGS = [ }, required: ["sectionId"] }, + { + name: "update_editorial_tile", + sectionType: "EDITORIAL_TILE", + description: `Update an EDITORIAL_TILE section. Title, description, and buttonText are localized. imageUrl, imagePosition, buttonLink, backgroundColor, and textColor are plain strings. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://..." }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + imageUrl: { type: "string", description: "Plain string main image URL" }, + imagePosition: { type: "string", description: "Plain string image position ('left' or 'right')" }, + title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, + description: { ...localizedStringSchema, description: `Localized description, for example ${localizedStringExample}` }, + buttonText: { ...localizedStringSchema, description: `Localized button text, for example ${localizedStringExample}` }, + buttonLink: { type: "string", description: "Plain string button link URL" }, + backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" }, + textColor: { type: "string", description: "Plain string text color in hex or rgba" } + }, + required: ["sectionId"] + }, { name: "update_rich_text", sectionType: "RICH_TEXT", @@ -150,6 +167,35 @@ const SECTION_TOOL_CONFIGS = [ subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` } }, required: ["sectionId"] + }, + { + name: "update_editorial_carousel", + sectionType: "EDITORIAL_CAROUSEL", + description: `Update an EDITORIAL_CAROUSEL section. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`, + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to update" }, + title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, + description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` }, + backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" }, + textColor: { type: "string", description: "Plain string text color in hex or rgba" }, + slides: { + type: "array", + items: { + type: "object", + properties: { + title: localizedStringSchema, + description: localizedStringSchema, + category: localizedStringSchema, + imageUrl: { type: "string" }, + buttonText: localizedStringSchema, + buttonLink: { type: "string" } + }, + required: ["imageUrl"] + }, + description: "Array of slides. IMPORTANT: Providing this will completely overwrite the existing slides array. Ensure you provide all slides." + } + }, + required: ["sectionId"] } ] as const; @@ -201,6 +247,18 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated } export const SECTION_TOOL_DEFINITIONS = [ + { + name: "get_page_section", + description: "Get a specific page section by ID, returning its hydrated payload resolving linked content like categories or products. Optionally pass lang to control translation.", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "The numeric ID of the section to fetch" }, + lang: { type: "string", description: "Optional language code (th, en, all)" } + }, + required: ["sectionId"] + } + }, { name: "add_page_section", description: "Add a block section to a dynamic page layout. For the payload field, ensure it matches the section type. Examples: For TEXT, use { \"text\": \"...\" }. For RICH_TEXT, use { \"title\": \"...\", \"content\": \"...\" }.", @@ -258,8 +316,21 @@ export const SECTION_TOOL_NAMES = new Set(SECTION_TOOL_DEFINITIONS.map(( export async function handleSectionTool(api: AxiosInstance, name: string, params: any) { if (!SECTION_TOOL_NAMES.has(name)) return null; + // Global validation: almost all section tools require sectionId, except add_page_section + if (name !== "add_page_section" && params.sectionId === undefined) { + throw new Error(`Validation Error: 'sectionId' is required for tool '${name}'`); + } + switch (name) { + case "get_page_section": { + const langParam = params.lang ? `?lang=${params.lang}` : ""; + const res = await api.get(`/pages/sections/${params.sectionId}${langParam}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } case "add_page_section": { + if (params.pageId === undefined) { + throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); + } const payload: any = { type: params.type, payload: params.payload, @@ -278,6 +349,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params const res = await api.put(`/pages/sections/${params.sectionId}`, payload); return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload); } + case "update_editorial_tile": case "update_hero_article": case "update_rich_text": case "update_scent_notes_breakdown": @@ -285,7 +357,8 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params case "update_product_carousel": case "update_accordion": case "update_contact_info": - case "update_contact_form": { + case "update_contact_form": + case "update_editorial_carousel": { const config = SECTION_TOOL_CONFIG_BY_NAME.get(name); if (!config) { throw new Error(`Unknown tool config for: ${name}`); @@ -299,9 +372,21 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params payload.heartNotes = payload.middleNotes; } } + + // Fetch existing section to merge payload so AI doesn't have to provide all required fields every time + let mergedPayload = payload; + try { + const existingRes = await api.get(`/pages/sections/${params.sectionId}?lang=all`); + if (existingRes.data && existingRes.data.payload) { + mergedPayload = { ...existingRes.data.payload, ...payload }; + } + } catch (err) { + console.warn(`Could not fetch existing section ${params.sectionId} for merging, proceeding with partial payload.`); + } + const res = await api.put(`/pages/sections/${params.sectionId}`, { type: config.sectionType, - ...payload + payload: mergedPayload }); return buildSectionUpdateResult(name, config.sectionType, res.data, payload); diff --git a/apps/spa/src/components/DynamicEditorialCarousel.tsx b/apps/spa/src/components/DynamicEditorialCarousel.tsx index 5df676d..0f35f0b 100644 --- a/apps/spa/src/components/DynamicEditorialCarousel.tsx +++ b/apps/spa/src/components/DynamicEditorialCarousel.tsx @@ -46,7 +46,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa }; return ( -
+

@@ -80,7 +80,6 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa (e.target as HTMLImageElement).style.display = 'none'; }} /> -

diff --git a/apps/spa/src/components/DynamicEditorialTile.tsx b/apps/spa/src/components/DynamicEditorialTile.tsx index 73d6fea..a084057 100644 --- a/apps/spa/src/components/DynamicEditorialTile.tsx +++ b/apps/spa/src/components/DynamicEditorialTile.tsx @@ -39,7 +39,8 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr return (
-

+

{renderTitle(title) || title}

-

+

{description}

{buttonText && buttonLink && ( diff --git a/apps/spa/src/components/DynamicProductCarousel.tsx b/apps/spa/src/components/DynamicProductCarousel.tsx index aa43476..7b16e61 100644 --- a/apps/spa/src/components/DynamicProductCarousel.tsx +++ b/apps/spa/src/components/DynamicProductCarousel.tsx @@ -1,9 +1,6 @@ -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Autoplay, Pagination, Navigation } from 'swiper/modules'; import ProductCard from './ProductCard'; -import 'swiper/css'; -import 'swiper/css/pagination'; -import 'swiper/css/navigation'; +import { useRef } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; @@ -19,6 +16,10 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous const title = getLocalizedField(p.title, lang); const description = getLocalizedField(p.description, lang); + const carouselRef = useRef(null); + const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' }); + const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }); + return (
@@ -34,27 +35,28 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous No products to display.
) : ( -
- + +
{products.map((p: any) => ( - +
- +
))} - +
+
)}
diff --git a/apps/spa/src/components/DynamicProducts.tsx b/apps/spa/src/components/DynamicProducts.tsx index 0d490b5..b9af2c3 100644 --- a/apps/spa/src/components/DynamicProducts.tsx +++ b/apps/spa/src/components/DynamicProducts.tsx @@ -1,15 +1,11 @@ import { Link } from 'react-router-dom'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Autoplay, Pagination } from 'swiper/modules'; +import { useRef } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import ProductCard from './ProductCard'; import type { Category, Product, ProductsSectionPayload } from '@homaroma/types'; import { useTranslation } from 'react-i18next'; import { getLocalizedField } from '../utils/localization'; -// Import Swiper styles -import 'swiper/css'; -import 'swiper/css/pagination'; - interface DynamicProductsProps { payload: ProductsSectionPayload & { products?: Product[] }; categories?: Category[]; @@ -23,6 +19,10 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro const subtitle = getLocalizedField(payload.subtitle, lang); const categoryLookup = new Map(categories.map((category) => [category.id, category])); + const carouselRef = useRef(null); + const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' }); + const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }); + return (
@@ -49,29 +49,32 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro No products found in this section
) : layout === 'carousel' ? ( -
- + +
{products.map((p) => ( - +
- +
))} - +
+
) : (
diff --git a/apps/spa/src/index.css b/apps/spa/src/index.css index a520f24..c45e5c1 100644 --- a/apps/spa/src/index.css +++ b/apps/spa/src/index.css @@ -235,6 +235,14 @@ @apply bg-pop-black/50; } +.hide-scrollbar::-webkit-scrollbar { + display: none; +} +.hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + /* Smooth Scrolling */ html { scroll-behavior: smooth; diff --git a/apps/website/src/app/globals.css b/apps/website/src/app/globals.css index 8603d54..3d4f69d 100644 --- a/apps/website/src/app/globals.css +++ b/apps/website/src/app/globals.css @@ -573,3 +573,11 @@ body:has(.announcement-bar) .global-navbar[data-scrolled="false"] { top: 44px; /* Adjusts Navbar down when AnnouncementBar is present at the top */ } + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} +.hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index bc06277..6bd5647 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -2,8 +2,8 @@ import Image from 'next/image' import Link from '../LocalizedLink' -import { Clock, Mail, MapPin, MessageCircle, Send } from 'lucide-react' -import { useState } from 'react' +import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight } from 'lucide-react' +import { useState, useRef } from 'react' import type { Category, PageSection, Product } from '@homaroma/types' import { getCategoryImage } from '@homaroma/types' import ProductCard from '../ProductCard' @@ -219,6 +219,10 @@ function ProductsSection({ payload, lang }: { payload: Record, const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang) const layout = typeof payload.layout === 'string' ? payload.layout : 'grid' const products = getProducts(payload) + + const carouselRef = useRef(null) + const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' }) + const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' }) return (
@@ -235,21 +239,27 @@ function ProductsSection({ payload, lang }: { payload: Record, {products.length ? ( layout === 'carousel' ? (
- + + +
{products.map((product) => ( - +
- +
))} - +
+
) : (
@@ -258,7 +268,7 @@ function ProductsSection({ payload, lang }: { payload: Record, ) ) : (

- {t('dynamic.emptyProducts', { lng: resolvedLang })} + {t('dynamic.noProducts', { lng: resolvedLang })}

)}
@@ -387,18 +397,20 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record +
{title && (
-

{renderAccentTitle(title)}

+

{renderAccentTitle(title)}

)} -
+
{slides.map((slide, index) => { const slideTitle = localizedFromRecord(slide, 'title', resolvedLang) const description = localizedFromRecord(slide, 'description', resolvedLang) @@ -408,8 +420,8 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record - {imageUrl && ( +
+ {imageUrl ? (
- )} -
- {category &&

{category}

} - {slideTitle &&

{renderAccentTitle(slideTitle)}

} - {description &&

{description}

} + ) : null} +
+
+ {category &&

{category}

} + {slideTitle &&

{renderAccentTitle(slideTitle)}

} + {description &&

{description}

} +
{buttonText && buttonLink && ( - - {buttonText} → - +
+ + {buttonText} → + +
)}
@@ -802,8 +818,9 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr case 'CATEGORIES': return case 'PRODUCTS': - case 'PRODUCT_CAROUSEL': return + case 'PRODUCT_CAROUSEL': + return case 'BANNER': return case 'TEXT': diff --git a/packages/types/index.ts b/packages/types/index.ts index 421db40..968c009 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -328,6 +328,8 @@ export type EditorialTileSectionPayload = z.infer; // Aliases for backwards compatibility in ScentGuideSectionSchema