From bd8f5882e076ce8a90412b3cbcf79d11f2632c9f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 04:04:32 -0400 Subject: [PATCH] x --- apps/backend/src/routes/pages.ts | 107 +++ apps/mcp-server/index.ts | 151 ++- apps/mcp-server/section-toolkit.test.ts | 136 +++ apps/mcp-server/section-toolkit.ts | 981 +++++++++++++++++++- apps/mcp-server/test_mcp_menu_challenger.ts | 2 +- apps/website/src/app/[lang]/layout.tsx | 2 +- apps/website/src/app/globals.css | 26 +- apps/website/src/app/layout.tsx | 2 +- packages/types/index.ts | 28 + 9 files changed, 1410 insertions(+), 25 deletions(-) create mode 100644 apps/mcp-server/section-toolkit.test.ts diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 73abe39..4d7696a 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -186,6 +186,113 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) }, ) .use(adminOnly) + .post( + "/with-section", + async ({ body, set }) => { + const { page: pageInput, section: sectionInput } = body as { + page: { + title: string; + slug: string; + isPublished?: boolean; + }; + section: { + type: string; + payload: unknown; + order?: number; + isActive?: boolean; + }; + }; + + if (RESERVED_SLUGS.includes(pageInput.slug.toLowerCase())) { + set.status = 400; + return { + success: false, + error: "Validation failed", + validationIssues: [ + { + path: "page.slug", + message: "Reserved slug cannot be used", + code: "custom", + }, + ], + }; + } + + const validationResult = validateSectionPayload( + sectionInput.type, + sectionInput.payload, + ); + if (!validationResult.success) { + set.status = 400; + return { + success: false, + error: "Validation failed", + validationIssues: summarizeValidationIssues( + validationResult.issues || [], + ).map((issue) => ({ + ...issue, + path: + issue.path === "(root)" + ? "section.payload" + : `section.payload.${issue.path}`, + })), + }; + } + + try { + const result = await prisma.$transaction(async (tx) => { + const page = await tx.page.create({ + data: { + title: pageInput.title, + slug: pageInput.slug, + isPublished: pageInput.isPublished ?? false, + }, + }); + const section = await tx.pageSection.create({ + data: { + pageId: page.id, + type: sectionInput.type.toUpperCase(), + payload: validationResult.data, + order: sectionInput.order ?? 1, + isActive: sectionInput.isActive ?? true, + }, + }); + + return { page, section }; + }); + + return { + success: true, + pageId: result.page.id, + sectionId: result.section.id, + page: result.page, + section: result.section, + }; + } catch (err) { + console.error("[Pages] Atomic page creation error:", err); + set.status = 500; + return { + success: false, + error: "Failed to create page with section", + }; + } + }, + { + body: t.Object({ + page: t.Object({ + title: t.String(), + slug: t.String(), + isPublished: t.Optional(t.Boolean()), + }), + section: t.Object({ + type: t.String(), + payload: t.Any(), + order: t.Optional(t.Number()), + isActive: t.Optional(t.Boolean()), + }), + }), + }, + ) .post( "/", async ({ body, set }) => { diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 4fcf521..505ae95 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -7,7 +7,7 @@ import { import axios from "axios"; import FormData from "form-data"; import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit"; -import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit"; +import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit"; const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api"; const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api"; @@ -22,6 +22,34 @@ export const api = axios.create({ baseURL: BACKEND_URL, }); +function currentTargetEnvironment() { + if (BACKEND_URL === LOCAL_BACKEND_URL) return "local"; + if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production"; + return "custom"; +} + +function normalizeToolError(err: any) { + const data = err.response?.data; + if (!data) { + return err.message; + } + + const validationIssues = data.validationIssues ?? data.issues; + if (Array.isArray(validationIssues) && validationIssues.length > 0) { + const missingOrInvalid = validationIssues + .map((issue: any) => `${issue.path ?? "(root)"}: ${issue.message ?? issue.code ?? "Invalid value"}`) + .join("; "); + return JSON.stringify({ + error: data.error ?? data.message ?? "Validation failed", + summary: missingOrInvalid, + validationIssues, + raw: data, + }); + } + + return JSON.stringify(data); +} + // MCP server state is process-local because this server runs over stdio. api.interceptors.request.use(async (config) => { @@ -81,6 +109,22 @@ function compactCategory(category: any) { }; } +function compactProduct(product: any) { + if (!product) return null; + const images = Array.isArray(product.images) ? product.images : []; + const firstImage = images[0]; + return { + id: product.id, + name: product.name, + slug: product.slug ?? undefined, + sku: product.sku ?? undefined, + price: product.price ?? product.variants?.[0]?.price ?? undefined, + imageUrl: firstImage?.url ?? firstImage?.imageUrl ?? product.imageUrl ?? null, + category: product.category ? compactCategory(product.category) : undefined, + brand: product.brand ? { id: product.brand.id, name: product.brand.name } : undefined, + }; +} + const server = new Server( { name: "homaroma-mcp", @@ -96,6 +140,11 @@ const server = new Server( server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ + { + name: "get_target_environment", + description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.", + inputSchema: { type: "object", properties: {} }, + }, { name: "set_target_environment", description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.", @@ -136,6 +185,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } } }, + { + name: "list_categories_metadata", + description: "List compact category IDs/names for Page Builder blocks. Use this before CATEGORIES.categoryIds or PRODUCTS.categoryId.", + inputSchema: { + type: "object", + properties: { + lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" } + } + } + }, { name: "get_category_products", description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.", @@ -210,6 +269,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { } } }, + { + name: "search_products_compact", + description: "Search products with compact output for Page Builder productIds. Prefer this over search_products before PRODUCTS.type=custom.", + inputSchema: { + type: "object", + properties: { + search: { type: "string", description: "Search term" }, + limit: { type: "number", description: "Max results. Default 10." }, + lang: { type: "string", description: "Language code (e.g. th, en, all)" }, + brandId: { type: "number", description: "Filter by brand ID" }, + categoryId: { type: "number", description: "Filter by category ID" } + } + } + }, { name: "get_product_detail", description: "Get full product details including variants, images, and category", @@ -853,7 +926,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: "object", properties: { slug: { type: "string", description: "Slug of the page" }, - includeInactive: { type: "boolean", description: "Include inactive sections" } + includeInactive: { type: "boolean", description: "Include inactive sections" }, + lang: { type: "string", description: "Preview/content language code. Default th." } }, required: ["slug"] } @@ -867,6 +941,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { title: { type: "string", description: "Title of the page" }, slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." }, isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." }, + lang: { type: "string", description: "Preview language code. Default th." }, id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." } }, required: ["title", "slug"] @@ -934,6 +1009,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } switch (request.params.name) { + case "get_target_environment": { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + env: currentTargetEnvironment(), + baseUrl: BACKEND_URL, + localBaseUrl: LOCAL_BACKEND_URL, + productionBaseUrl: PRODUCTION_BACKEND_URL, + authenticated: Boolean(authToken), + }, null, 2) + } + ] + }; + } case "set_target_environment": { const env = params.env; const customUrl = params.customUrl; @@ -973,6 +1064,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.get(`/categories${lang}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "list_categories_metadata": { + const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : ""; + const res = await api.get(`/categories${lang}`); + const categories = Array.isArray(res.data) ? res.data : (res.data?.data ?? []); + const compact = Array.isArray(categories) ? categories.map(compactCategory) : []; + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + count: compact.length, + categories: compact, + }, null, 2) + }] + }; + } case "get_category_products": { const { id } = params as any; const query = new URLSearchParams(); @@ -1132,6 +1239,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.get(`/products?${search}${limit}${lang}${brandId}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "search_products_compact": { + const query = new URLSearchParams(); + if (params.search) query.set("search", String(params.search)); + query.set("limit", String(params.limit ?? 10)); + if (params.lang) query.set("lang", String(params.lang)); + if (params.brandId) query.set("brandId", String(params.brandId)); + if (params.categoryId) query.set("categoryId", String(params.categoryId)); + const res = await api.get(`/products?${query.toString()}`); + const products = Array.isArray(res.data) ? res.data : (res.data?.data ?? []); + const compact = Array.isArray(products) ? products.map(compactProduct) : []; + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + count: compact.length, + products: compact, + meta: res.data?.meta ?? undefined, + }, null, 2) + }] + }; + } case "get_product_detail": { const lang = params.lang ? `?lang=${params.lang}` : ""; const res = await api.get(`/products/${params.id}${lang}`); @@ -1491,9 +1620,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] }; } case "get_dynamic_page_detail": { - const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : ""); + const query = new URLSearchParams(); + if (params.includeInactive) query.set("includeInactive", "true"); + if (params.lang) query.set("lang", String(params.lang)); + const url = `/pages/${params.slug}` + (query.toString() ? `?${query.toString()}` : ""); const res = await api.get(url); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + return { + content: [{ + type: "text", + text: JSON.stringify({ + ...res.data, + preview: buildCmsPreview(params.slug, params.lang || "th"), + }, null, 2) + }] + }; } case "create_dynamic_page": { const payload: any = { @@ -1515,6 +1655,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false, publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"), diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."), + preview: buildCmsPreview(params.slug, params.lang || "th"), page: pageData, }; return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] }; @@ -1567,7 +1708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`Unknown tool: ${request.params.name}`); } } catch (err: any) { - const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message; + const errorMsg = normalizeToolError(err); return { content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }], isError: true, diff --git a/apps/mcp-server/section-toolkit.test.ts b/apps/mcp-server/section-toolkit.test.ts new file mode 100644 index 0000000..00894cf --- /dev/null +++ b/apps/mcp-server/section-toolkit.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { + getPageBlockSchema, + handleSectionTool, + validatePayloadAgainstMetadata, +} from "./section-toolkit"; + +const websiteBlockTypes = [ + "HERO", + "ANNOUNCEMENT_BAR", + "NEWSLETTER", + "EDITORIAL_TILE", + "EDITORIAL_CAROUSEL", + "PRODUCTS", + "CATEGORIES", + "TEXT", + "BANNER", + "HERO_ARTICLE", + "RICH_TEXT", + "SCENT_NOTES_BREAKDOWN", + "QUOTE_CALLOUT", + "PRODUCT_CAROUSEL", + "ACCORDION", + "CONTACT_INFO", + "CONTACT_FORM", + "HERO_V2", + "USP_BAR", + "CUSTOM", +] as const; + +describe("Page Builder block contracts", () => { + for (const type of websiteBlockTypes) { + test(`${type} exposes a valid recommended payload`, () => { + const schema = getPageBlockSchema(type); + + expect(schema.renderability.websitePageRenderable).toBe(true); + expect(schema.recommendedPayload).not.toBeNull(); + expect(validatePayloadAgainstMetadata(type, schema.recommendedPayload).valid).toBe(true); + }); + } + + test("HERO_V2 rejects the legacy empty payload before backend writes", () => { + const result = validatePayloadAgainstMetadata("HERO_V2", {}); + + expect(result.valid).toBe(false); + expect(result.missingFields).toEqual(["title", "imageUrl"]); + }); + + test("USP_BAR rejects an empty payload because the website renders nothing", () => { + const result = validatePayloadAgainstMetadata("USP_BAR", {}); + + expect(result.valid).toBe(false); + expect(result.missingFields).toContain("items"); + }); + + test("CUSTOM rejects component IDs missing from the website registry", () => { + const result = validatePayloadAgainstMetadata("CUSTOM", { + componentId: "UnknownComponent", + props: {}, + }); + + expect(result.valid).toBe(false); + expect(result.validationIssues).toContainEqual({ + path: "componentId", + message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.", + code: "custom", + }); + }); + + test("create_page_with_section rejects invalid payloads without calling backend", async () => { + let postCalls = 0; + const api = { + post: async () => { + postCalls += 1; + throw new Error("Backend must not be called"); + }, + } as any; + + const result = await handleSectionTool(api, "create_page_with_section", { + title: "Invalid Hero", + slug: "invalid-hero", + type: "HERO_V2", + payload: {}, + }); + + expect(result && "isError" in result ? result.isError : false).toBe(true); + expect(postCalls).toBe(0); + }); + + test("create_page_with_section uses one atomic backend call and returns compact output", async () => { + const calls: Array<{ url: string; payload: unknown }> = []; + const api = { + post: async (url: string, payload: unknown) => { + calls.push({ url, payload }); + return { + data: { + success: true, + pageId: 50, + sectionId: 120, + page: { id: 50, slug: "hero-v2" }, + section: { id: 120, type: "HERO_V2" }, + }, + }; + }, + } as any; + const payload = getPageBlockSchema("HERO_V2").recommendedPayload; + + const result = await handleSectionTool(api, "create_page_with_section", { + title: "Hero V2", + slug: "hero-v2", + type: "HERO_V2", + payload, + isPublished: false, + }); + const response = JSON.parse(result?.content[0]?.text ?? "{}"); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/pages/with-section"); + expect(response).toEqual({ + success: true, + tool: "create_page_with_section", + pageId: 50, + pageSlug: "hero-v2", + sectionId: 120, + type: "HERO_V2", + preview: { + lang: "th", + previewPath: "/th/hero-v2", + previewUrl: "/th/hero-v2", + }, + warnings: [ + "contentAlignment must be left, center, or right. Use an image URL allowed by apps/website.", + ], + }); + }); +}); diff --git a/apps/mcp-server/section-toolkit.ts b/apps/mcp-server/section-toolkit.ts index dc61e3a..49c3e12 100644 --- a/apps/mcp-server/section-toolkit.ts +++ b/apps/mcp-server/section-toolkit.ts @@ -1,6 +1,43 @@ import crypto from "crypto"; import type { AxiosInstance } from "axios"; -import { PAGE_SECTION_TYPES } from "@homaroma/types"; +import { + PAGE_SECTION_PAYLOAD_SCHEMAS, + PAGE_SECTION_TYPES, + type SectionType, +} from "@homaroma/types"; + +const WEBSITE_PAGE_SECTION_TYPE_LIST = [ + "HERO", + "ANNOUNCEMENT_BAR", + "NEWSLETTER", + "EDITORIAL_TILE", + "EDITORIAL_CAROUSEL", + "PRODUCTS", + "CATEGORIES", + "TEXT", + "BANNER", + "HERO_ARTICLE", + "RICH_TEXT", + "SCENT_NOTES_BREAKDOWN", + "QUOTE_CALLOUT", + "PRODUCT_CAROUSEL", + "ACCORDION", + "CONTACT_INFO", + "CONTACT_FORM", + "HERO_V2", + "USP_BAR", + "CUSTOM", +] as const; + +const WEBSITE_PAGE_SECTION_TYPES = new Set(WEBSITE_PAGE_SECTION_TYPE_LIST); + +const PRODUCT_DETAIL_NATIVE_SECTION_TYPES = new Set([ + "PRODUCT_MAIN", + "PRODUCT_DETAILS", + "PRODUCT_SPECS", + "PRODUCT_REVIEWS", + "RELATED_PRODUCTS", +]); const localizedStringSchema = { oneOf: [ @@ -21,6 +58,426 @@ const localizedStringExample = '{ "th": "ข้อความภาษาไท const localizedStringListExample = `[ ${localizedStringExample} ]`; const categoryIdListExample = `[1, 2]`; const productIdListExample = `[101, 102]`; +const defaultLang = "th"; + +type BlockMetadata = { + type: string; + requiredFields: string[]; + optionalFields: string[]; + renderedFields: string[]; + storedOnlyFields: string[]; + discoveryToolsNeeded: string[]; + warnings: string[]; + minimalPayload: Record; + recommendedPayload: Record; +}; + +const imageUrlExample = "https://elysia3051.kitizone.com/uploads/2b3cca4c-9f68-43c0-982b-097475f9e55f.jpeg"; +const localizedObjectExample = { th: "ข้อความภาษาไทย", en: "English text" }; + +const PAGE_BLOCK_METADATA: Record = { + HERO: { + type: "HERO", + requiredFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink"], + optionalFields: ["focalPoint"], + renderedFields: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink", "focalPoint"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Use an image URL from a host allowed by apps/website next.config.ts."], + minimalPayload: { + tagline: localizedObjectExample, + title: localizedObjectExample, + description: localizedObjectExample, + imageUrl: imageUrlExample, + buttonText: localizedObjectExample, + buttonLink: "/shop", + }, + recommendedPayload: { + tagline: { th: "ทดสอบ HERO", en: "HERO test" }, + title: { th: "บล็อก HERO", en: "HERO Block" }, + description: { th: "ทดสอบการแสดงผล hero ผ่าน MCP", en: "Testing HERO rendering through MCP." }, + imageUrl: imageUrlExample, + buttonText: { th: "ดูสินค้า", en: "Shop now" }, + buttonLink: "/shop", + focalPoint: { x: "50%", y: "50%" }, + }, + }, + ANNOUNCEMENT_BAR: { + type: "ANNOUNCEMENT_BAR", + requiredFields: ["slides"], + optionalFields: ["backgroundColor", "textColor", "speed"], + renderedFields: ["slides", "backgroundColor", "textColor", "speed"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: [], + minimalPayload: { slides: [localizedObjectExample] }, + recommendedPayload: { + slides: [ + { th: "ส่งฟรีเมื่อครบยอดที่กำหนด", en: "Free shipping on qualifying orders" }, + { th: "ทดสอบแถบประกาศผ่าน MCP", en: "Announcement bar created via MCP" }, + ], + backgroundColor: "#1F3D2D", + textColor: "#FDFDF9", + speed: 3000, + }, + }, + NEWSLETTER: { + type: "NEWSLETTER", + requiredFields: ["title", "description", "buttonText", "showPrivacyCheckbox"], + optionalFields: ["placeholderText", "privacyText"], + renderedFields: ["title", "description", "buttonText", "placeholderText"], + storedOnlyFields: ["showPrivacyCheckbox", "privacyText"], + discoveryToolsNeeded: [], + warnings: ["showPrivacyCheckbox and privacyText are accepted by backend schema but are not rendered by apps/website NewsletterSection today."], + minimalPayload: { + title: localizedObjectExample, + description: localizedObjectExample, + buttonText: localizedObjectExample, + showPrivacyCheckbox: true, + }, + recommendedPayload: { + title: { th: "รับข่าวสารจากเรา", en: "Join our newsletter" }, + description: { th: "รับข้อเสนอและข่าวสินค้าใหม่", en: "Get offers and new product updates." }, + buttonText: { th: "สมัครรับข่าวสาร", en: "Subscribe" }, + placeholderText: { th: "อีเมลของคุณ", en: "Your email" }, + showPrivacyCheckbox: true, + }, + }, + EDITORIAL_TILE: { + type: "EDITORIAL_TILE", + requiredFields: ["title", "description", "imageUrl", "imagePosition"], + optionalFields: ["buttonText", "buttonLink", "backgroundColor", "textColor"], + renderedFields: ["title", "description", "imageUrl", "imagePosition", "buttonText", "buttonLink", "backgroundColor", "textColor"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["imagePosition must be left or right."], + minimalPayload: { + title: localizedObjectExample, + description: localizedObjectExample, + imageUrl: imageUrlExample, + imagePosition: "left", + }, + recommendedPayload: { + title: { th: "เรื่องราวของกลิ่นหอม", en: "A Scented Story" }, + description: { th: "ทดสอบ editorial tile พร้อมรูปและปุ่ม", en: "Testing an editorial tile with image and action." }, + imageUrl: imageUrlExample, + imagePosition: "left", + buttonText: { th: "อ่านต่อ", en: "Read more" }, + buttonLink: "/about", + }, + }, + EDITORIAL_CAROUSEL: { + type: "EDITORIAL_CAROUSEL", + requiredFields: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"], + optionalFields: ["backgroundColor", "textColor", "layout", "imagePosition", "slides[].buttonText", "slides[].buttonLink"], + renderedFields: ["title", "backgroundColor", "textColor", "layout", "imagePosition", "slides"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["When updating slides, provide the complete slides array. The backend stores the replacement array."], + minimalPayload: { + title: localizedObjectExample, + slides: [{ + category: localizedObjectExample, + title: localizedObjectExample, + description: localizedObjectExample, + imageUrl: imageUrlExample, + }], + }, + recommendedPayload: { + title: { th: "สไลด์เรื่องราว", en: "Editorial Slides" }, + layout: "standard", + slides: [{ + category: { th: "แหล่งที่มา", en: "Origin" }, + title: { th: "แรงบันดาลใจจากธรรมชาติ", en: "Inspired by nature" }, + description: { th: "ทดสอบ slide แรก", en: "Testing the first slide." }, + imageUrl: imageUrlExample, + buttonText: { th: "ดูเพิ่มเติม", en: "Learn more" }, + buttonLink: "/about", + }], + }, + }, + PRODUCTS: { + type: "PRODUCTS", + requiredFields: ["title", "type"], + optionalFields: ["subtitle", "categoryId", "brandId", "productIds", "limit", "layout", "enableQuickShop"], + renderedFields: ["title", "subtitle", "layout", "products"], + storedOnlyFields: ["type", "categoryId", "brandId", "productIds", "limit", "enableQuickShop"], + discoveryToolsNeeded: ["search_products_compact when type=custom", "list_categories_metadata when categoryId is used", "list_product_brands when brandId is used"], + warnings: ["Prefer type=featured for first attempts. Use compact discovery before custom productIds."], + minimalPayload: { + title: localizedObjectExample, + type: "featured", + limit: 4, + }, + recommendedPayload: { + title: { th: "สินค้าแนะนำ", en: "Featured products" }, + subtitle: { th: "ทดสอบ products block", en: "Testing the products block." }, + type: "featured", + limit: 3, + layout: "grid", + }, + }, + CATEGORIES: { + type: "CATEGORIES", + requiredFields: ["title"], + optionalFields: ["subtitle", "categoryIds"], + renderedFields: ["title", "subtitle", "categoryIds", "categories"], + storedOnlyFields: [], + discoveryToolsNeeded: ["list_categories_metadata when categoryIds are specified"], + warnings: ["If categoryIds are omitted, website renders all active categories provided by the route."], + minimalPayload: { title: localizedObjectExample }, + recommendedPayload: { + title: { th: "หมวดหมู่สินค้า", en: "Shop categories" }, + subtitle: { th: "ทดสอบ categories block", en: "Testing categories block." }, + }, + }, + TEXT: { + type: "TEXT", + requiredFields: ["content"], + optionalFields: ["title", "textAlign"], + renderedFields: ["title", "content", "textAlign"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["content may contain HTML and is rendered with dangerouslySetInnerHTML in apps/website."], + minimalPayload: { content: localizedObjectExample }, + recommendedPayload: { + title: { th: "ข้อความทดสอบ", en: "Text test" }, + content: { th: "

นี่คือ TEXT block ที่สร้างผ่าน MCP

", en: "

This is a TEXT block created through MCP.

" }, + textAlign: "center", + }, + }, + BANNER: { + type: "BANNER", + requiredFields: ["imageUrl"], + optionalFields: ["linkUrl", "title", "description", "buttonText", "focalPoint"], + renderedFields: ["imageUrl", "linkUrl", "title", "description", "buttonText", "focalPoint"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Use linkUrl when buttonText is present so the visual CTA navigates somewhere."], + minimalPayload: { imageUrl: imageUrlExample, linkUrl: "/shop" }, + recommendedPayload: { + imageUrl: imageUrlExample, + linkUrl: "/shop", + title: { th: "แบนเนอร์ทดสอบ", en: "Banner test" }, + description: { th: "ทดสอบ BANNER block", en: "Testing BANNER block." }, + buttonText: { th: "ซื้อเลย", en: "Shop now" }, + focalPoint: { x: "50%", y: "50%" }, + }, + }, + HERO_ARTICLE: { + type: "HERO_ARTICLE", + requiredFields: ["title"], + optionalFields: ["subtitle", "author", "category", "publishDate", "date", "imageUrl", "backgroundImageUrl", "overlayColor"], + renderedFields: ["title", "subtitle", "author", "category", "publishDate", "date", "imageUrl", "backgroundImageUrl", "focalPoint"], + storedOnlyFields: ["overlayColor"], + discoveryToolsNeeded: [], + warnings: ["overlayColor is accepted by backend schema but is not used by apps/website HeroArticleSection today."], + minimalPayload: { title: localizedObjectExample }, + recommendedPayload: { + title: { th: "บทความทดสอบ", en: "Hero article test" }, + subtitle: { th: "ทดสอบ HERO_ARTICLE block", en: "Testing HERO_ARTICLE block." }, + author: { th: "ทีม HomAroma", en: "HomAroma Team" }, + category: { th: "คู่มือกลิ่น", en: "Scent Guide" }, + publishDate: "2026-07-24", + imageUrl: imageUrlExample, + }, + }, + RICH_TEXT: { + type: "RICH_TEXT", + requiredFields: ["content"], + optionalFields: ["title"], + renderedFields: ["title", "content"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["content may contain HTML and is rendered as CMS-authored rich text."], + minimalPayload: { content: localizedObjectExample }, + recommendedPayload: { + title: { th: "เรื่องราวของกลิ่นหอม", en: "The Story of Fragrance" }, + content: { + th: "

สำรวจศิลปะแห่งกลิ่นหอมผ่านวัตถุดิบที่คัดสรร

", + en: "

Explore the art of fragrance through carefully selected ingredients.

", + }, + }, + }, + SCENT_NOTES_BREAKDOWN: { + type: "SCENT_NOTES_BREAKDOWN", + requiredFields: [], + optionalFields: ["title", "description", "topNotes", "heartNotes", "middleNotes", "baseNotes"], + renderedFields: ["title", "description", "topNotes", "heartNotes", "middleNotes", "baseNotes"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Use heartNotes or middleNotes for the middle layer. Type-specific update tools mirror them for compatibility."], + minimalPayload: { title: localizedObjectExample, topNotes: [localizedObjectExample] }, + recommendedPayload: { + title: { th: "โครงสร้างกลิ่น", en: "Scent Structure" }, + description: { th: "กลิ่นที่ค่อย ๆ เปิดเผยตามเวลา", en: "Notes that unfold over time." }, + topNotes: [{ th: "เบอร์กามอต", en: "Bergamot" }], + heartNotes: [{ th: "มะลิ", en: "Jasmine" }], + baseNotes: [{ th: "ไม้จันทน์", en: "Sandalwood" }], + }, + }, + QUOTE_CALLOUT: { + type: "QUOTE_CALLOUT", + requiredFields: ["quote"], + optionalFields: ["author", "role"], + renderedFields: ["quote", "author", "role"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: [], + minimalPayload: { quote: localizedObjectExample }, + recommendedPayload: { + quote: { th: "กลิ่นหอมคือความทรงจำที่มองไม่เห็น", en: "Fragrance is an invisible memory." }, + author: { th: "ทีม HomAroma", en: "HomAroma Team" }, + role: { th: "นักสร้างสรรค์กลิ่น", en: "Scent Creators" }, + }, + }, + PRODUCT_CAROUSEL: { + type: "PRODUCT_CAROUSEL", + requiredFields: ["type"], + optionalFields: ["title", "description", "categoryId", "brandId", "productIds", "limit", "showPrice", "showAddToCart"], + renderedFields: ["title", "description", "products", "showPrice", "showAddToCart"], + storedOnlyFields: ["type", "categoryId", "brandId", "productIds", "limit"], + discoveryToolsNeeded: ["search_products_compact when type=custom", "list_categories_metadata when categoryId is used", "list_product_brands when brandId is used"], + warnings: ["Prefer type=featured for first attempts. Discover real IDs before using custom, category, or brand modes."], + minimalPayload: { type: "featured", limit: 4 }, + recommendedPayload: { + type: "featured", + title: { th: "สินค้าที่คัดสรร", en: "Featured Products" }, + description: { th: "กลิ่นหอมยอดนิยมที่เราเลือกให้คุณ", en: "Popular fragrances selected for you." }, + limit: 4, + showPrice: true, + showAddToCart: true, + }, + }, + ACCORDION: { + type: "ACCORDION", + requiredFields: ["categories", "categories[].name", "categories[].questions"], + optionalFields: ["title", "backgroundColor", "textColor", "iconColor", "categories[].icon"], + renderedFields: ["title", "backgroundColor", "textColor", "iconColor", "categories"], + storedOnlyFields: ["categories[].id", "categories[].questions[].id"], + discoveryToolsNeeded: [], + warnings: ["The backend permits categories to be omitted, but apps/website renders nothing when categories is empty."], + minimalPayload: { + categories: [{ + name: localizedObjectExample, + questions: [{ q: localizedObjectExample, a: localizedObjectExample }], + }], + }, + recommendedPayload: { + title: { th: "คำถามที่พบบ่อย", en: "Frequently Asked Questions" }, + categories: [{ + name: { th: "การเลือกกลิ่น", en: "Choosing a Scent" }, + questions: [{ + q: { th: "ควรเริ่มอย่างไร", en: "How should I start?" }, + a: { th: "เริ่มจากโน้ตกลิ่นที่คุณชอบ", en: "Start with notes you already enjoy." }, + }], + }], + }, + }, + CONTACT_INFO: { + type: "CONTACT_INFO", + requiredFields: ["address", "email", "line", "hours"], + optionalFields: ["title"], + renderedFields: ["title", "address", "email", "line", "hours"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: [], + minimalPayload: { + address: localizedObjectExample, + email: "hello@homaroma.shop", + line: "@homaroma", + hours: localizedObjectExample, + }, + recommendedPayload: { + title: { th: "ติดต่อเรา", en: "Contact Us" }, + address: { th: "กรุงเทพมหานคร ประเทศไทย", en: "Bangkok, Thailand" }, + email: "hello@homaroma.shop", + line: "@homaroma", + hours: { th: "ทุกวัน 10:00-18:00 น.", en: "Daily, 10:00 AM-6:00 PM" }, + }, + }, + CONTACT_FORM: { + type: "CONTACT_FORM", + requiredFields: [], + optionalFields: ["title", "subtitle"], + renderedFields: ["title", "subtitle"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Form labels and submission behavior come from apps/website; this payload controls heading copy only."], + minimalPayload: {}, + recommendedPayload: { + title: { th: "ส่งข้อความถึงเรา", en: "Send Us a Message" }, + subtitle: { th: "ทีมงานจะติดต่อกลับโดยเร็ว", en: "Our team will get back to you shortly." }, + }, + }, + HERO_V2: { + type: "HERO_V2", + requiredFields: ["title", "imageUrl"], + optionalFields: ["tagline", "description", "contentAlignment", "showOrnament", "primaryButtonText", "primaryButtonLink", "secondaryButtonText", "secondaryButtonLink", "focalPoint"], + renderedFields: ["tagline", "title", "description", "imageUrl", "contentAlignment", "showOrnament", "primaryButtonText", "primaryButtonLink", "secondaryButtonText", "secondaryButtonLink", "focalPoint"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["contentAlignment must be left, center, or right. Use an image URL allowed by apps/website."], + minimalPayload: { title: localizedObjectExample, imageUrl: imageUrlExample }, + recommendedPayload: { + tagline: { th: "คอลเลกชันใหม่", en: "New Collection" }, + title: { th: "กลิ่นหอมที่บอกเล่าตัวตน", en: "A Fragrance That Tells Your Story" }, + description: { th: "ค้นพบกลิ่นที่เข้ากับช่วงเวลาของคุณ", en: "Discover a scent for your moment." }, + imageUrl: imageUrlExample, + contentAlignment: "left", + showOrnament: true, + primaryButtonText: { th: "เลือกซื้อสินค้า", en: "Shop Now" }, + primaryButtonLink: "/shop", + focalPoint: { x: "50%", y: "50%" }, + }, + }, + USP_BAR: { + type: "USP_BAR", + requiredFields: ["items", "items[].icon", "items[].title"], + optionalFields: ["backgroundColor", "items[].subtitle"], + renderedFields: ["backgroundColor", "items"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Provide 1-4 items. The backend permits no items, but apps/website renders nothing for an empty list.", "Supported icons include leaf, droplet, sparkles, gift, check, shield, and heart."], + minimalPayload: { + items: [{ icon: "leaf", title: localizedObjectExample }], + }, + recommendedPayload: { + backgroundColor: "#F7F5F0", + items: [ + { icon: "leaf", title: { th: "วัตถุดิบคัดสรร", en: "Selected Ingredients" }, subtitle: { th: "ใส่ใจในทุกรายละเอียด", en: "Care in every detail" } }, + { icon: "gift", title: { th: "เหมาะสำหรับเป็นของขวัญ", en: "Gift Ready" }, subtitle: { th: "ส่งต่อความประทับใจ", en: "Made to delight" } }, + ], + }, + }, + CUSTOM: { + type: "CUSTOM", + requiredFields: ["componentId"], + optionalFields: ["props"], + renderedFields: ["componentId", "props"], + storedOnlyFields: [], + discoveryToolsNeeded: [], + warnings: ["Supported componentId values are BakeryCategories and StoryFeatureMosaic. Unknown IDs are stored by backend but render nothing on apps/website."], + minimalPayload: { + componentId: "BakeryCategories", + props: {}, + }, + recommendedPayload: { + componentId: "BakeryCategories", + props: { + title: { th: "เลือกตามบรรยากาศ", en: "Shop by Mood" }, + subtitle: { th: "ค้นหากลิ่นที่เหมาะกับคุณ", en: "Find a scent that suits you." }, + layout: "horizontal", + items: [{ + title: { th: "ผ่อนคลาย", en: "Relax" }, + description: { th: "กลิ่นนุ่มนวลสำหรับวันสบาย", en: "Soft scents for quiet days." }, + href: "/shop", + icon: "leaf", + }], + }, + }, + }, +}; const ADD_PAGE_SECTION_GUIDANCE: Record = { HERO: { @@ -107,12 +564,166 @@ const ADD_PAGE_SECTION_GUIDANCE: Record 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +} + +export function validatePayloadAgainstMetadata(sectionType: string, payload: any) { + const schema = getPageBlockSchema(sectionType); + const normalizedType = sectionType.toUpperCase() as SectionType; + const normalizedPayload = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {}; + const missingFields: string[] = []; + + for (const field of schema.requiredFields) { + if (field.includes("[].")) { + const [arrayField, childField] = field.split("[].") as [string, string]; + const items = normalizedPayload[arrayField]; + if (!Array.isArray(items) || items.length === 0 || !items.every((item) => hasRenderableValue(item?.[childField]))) { + missingFields.push(field); + } + continue; + } + + if (!hasRenderableValue(normalizedPayload[field])) { + missingFields.push(field); + } + } + + const payloadSchema = PAGE_SECTION_PAYLOAD_SCHEMAS[normalizedType]; + const schemaResult = payloadSchema?.safeParse(normalizedPayload); + const validationIssues = schemaResult && !schemaResult.success + ? schemaResult.error.issues.map((issue) => ({ + path: issue.path.join(".") || "(root)", + message: issue.message, + code: issue.code, + })) + : []; + + if ( + normalizedType === "CUSTOM" && + hasRenderableValue(normalizedPayload.componentId) && + !["BakeryCategories", "StoryFeatureMosaic"].includes(normalizedPayload.componentId) + ) { + validationIssues.push({ + path: "componentId", + message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.", + code: "custom", + }); + } + + return { + valid: missingFields.length === 0 && validationIssues.length === 0, + missingFields, + validationIssues, + ...schema, + }; +} + +function normalizeSectionOrderItems(params: any) { + const rawItems = params.sectionOrders ?? params.sections; + if (!Array.isArray(rawItems) || rawItems.length === 0) { + throw new Error("Validation Error: sectionOrders must be a non-empty array of { id, order } items."); + } + return rawItems.map((item: any, index: number) => { + const id = Number(item.id); + if (!Number.isFinite(id)) { + throw new Error(`Validation Error: sectionOrders[${index}].id must be a number.`); + } + return { + id, + order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1, + }; + }); +} + function buildAddPageSectionDescription() { const toolSummaries = PAGE_SECTION_TYPES.map((type) => { const guide = ADD_PAGE_SECTION_GUIDANCE[type] ?? { @@ -384,6 +995,49 @@ function buildSectionUpdateResult( } export const SECTION_TOOL_DEFINITIONS = [ + { + name: "get_page_block_schema", + description: "Return LLM-friendly schema and website render metadata for any supported Page Builder block type. Use this before creating a website block.", + inputSchema: { + type: "object", + properties: { + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type to inspect" } + }, + required: ["type"] + } + }, + { + name: "validate_page_section_payload", + description: "Validate a Page Builder section payload shape without writing to the backend. This checks required fields and returns renderedFields/storedOnlyFields guidance for LLMs.", + inputSchema: { + type: "object", + properties: { + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type to validate" }, + payload: { type: "object", description: "Payload to validate" } + }, + required: ["type", "payload"] + } + }, + { + name: "create_page_with_section", + description: "Create or update a dynamic CMS page and add one website-renderable section in one operation. Use this when the user asks to create a page with a single block.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Page title" }, + slug: { type: "string", description: "Unique page slug" }, + isPublished: { type: "boolean", description: "Publish status; defaults to false if omitted by backend" }, + lang: { type: "string", description: "Preview language code. Default th." }, + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Website-renderable block type" }, + order: { type: "number", description: "Section order. Default 1." }, + payload: { type: "object", description: "Section payload matching block schema" }, + isActive: { type: "boolean", description: "Section active state. Default true." }, + allowProductNative: { type: "boolean", description: "Legacy escape hatch. Default false." }, + includeDebug: { type: "boolean", description: "Return full validation, page, and section records. Default false for a compact LLM response." } + }, + required: ["title", "slug", "type", "payload"] + } + }, { 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.", @@ -398,7 +1052,7 @@ export const SECTION_TOOL_DEFINITIONS = [ }, { name: "add_page_section", - description: buildAddPageSectionDescription(), + description: `${buildAddPageSectionDescription()}\n\nRender target guard: generic CMS pages are rendered by apps/website DynamicPageRenderer. PRODUCT_MAIN, PRODUCT_DETAILS, PRODUCT_SPECS, PRODUCT_REVIEWS, and RELATED_PRODUCTS are product detail native blocks and are rejected by default here. Use product detail CMS tools for those blocks.`, inputSchema: { type: "object", properties: { @@ -406,7 +1060,10 @@ export const SECTION_TOOL_DEFINITIONS = [ type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL, CUSTOM)" }, order: { type: "number", description: "Display order sequence (starts at 1)" }, payload: { type: "object", description: "The JSON payload matching the exact schema for the chosen type. See the tool description for required fields and minimal valid examples per type." }, - isActive: { type: "boolean", description: "Initial active state" } + isActive: { type: "boolean", description: "Initial active state" }, + pageSlug: { type: "string", description: "Optional page slug. If provided, the response includes a website preview URL/path." }, + lang: { type: "string", description: "Preview language code. Default th." }, + allowProductNative: { type: "boolean", description: "Legacy escape hatch. Default false. Set true only if you intentionally want to store a product-native block on a generic CMS page even though apps/website will not render it there." } }, required: ["pageId", "type", "payload"] } @@ -421,7 +1078,8 @@ export const SECTION_TOOL_DEFINITIONS = [ type: { type: "string", enum: [...PAGE_SECTION_TYPES], 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" } + isActive: { type: "boolean", description: "Optional new active state" }, + allowProductNative: { type: "boolean", description: "Legacy escape hatch. Default false. Required if changing a generic page section to a product-native type that apps/website does not render on generic CMS pages." } }, required: ["sectionId"] } @@ -503,6 +1161,29 @@ export const SECTION_TOOL_DEFINITIONS = [ required: ["sectionId", "categoryId", "questionId"] } }, + { + name: "reorder_page_sections", + description: "Reorder sections on a dynamic CMS page. Use this for generic pages managed by /pages.", + inputSchema: { + type: "object", + properties: { + pageId: { type: "number", description: "Numeric page ID" }, + sectionOrders: { + type: "array", + description: "Array of section order items, for example [{ \"id\": 10, \"order\": 1 }].", + items: { + type: "object", + properties: { + id: { type: "number", description: "Section ID" }, + order: { type: "number", description: "Target order starting at 1" } + }, + required: ["id", "order"] + } + } + }, + required: ["pageId", "sectionOrders"] + } + }, { name: "delete_page_section", description: "Delete a block section from a dynamic page layout", @@ -513,6 +1194,107 @@ export const SECTION_TOOL_DEFINITIONS = [ }, required: ["sectionId"] } + }, + { + name: "delete_dynamic_page", + description: "Delete a dynamic CMS page by numeric page ID. This permanently deletes the page and its sections in the backend.", + inputSchema: { + type: "object", + properties: { + pageId: { type: "number", description: "Numeric page ID to delete. Do not pass a slug." } + }, + required: ["pageId"] + } + }, + { + name: "get_product_detail_sections", + description: "Get CMS layout sections for a product detail page. These use /products/:id/detail-sections and are separate from generic dynamic pages.", + inputSchema: { + type: "object", + properties: { + productId: { type: "number", description: "Product ID" }, + includeInactive: { type: "boolean", description: "Include inactive sections" }, + lang: { type: "string", description: "Language code (th, en, all)" } + }, + required: ["productId"] + } + }, + { + name: "seed_product_detail_sections", + description: "Seed the default product detail CMS layout for one product. Backend rejects this if sections already exist.", + inputSchema: { + type: "object", + properties: { + productId: { type: "number", description: "Product ID" } + }, + required: ["productId"] + } + }, + { + name: "add_product_detail_section", + description: "Add a CMS section to a product detail page. Use this for PRODUCT_MAIN, PRODUCT_DETAILS, PRODUCT_SPECS, PRODUCT_REVIEWS, RELATED_PRODUCTS, and product-detail compatible marketing blocks.", + inputSchema: { + type: "object", + properties: { + productId: { type: "number", description: "Product ID" }, + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Product detail section type" }, + order: { type: "number", description: "Optional target order" }, + payload: { type: "object", description: "Section payload matching backend validation for the type" }, + isActive: { type: "boolean", description: "Initial active state" }, + isLocked: { type: "boolean", description: "Optional locked state. Use with care." } + }, + required: ["productId", "type", "payload"] + } + }, + { + name: "update_product_detail_section", + description: "Update an existing product detail CMS section. If payload is omitted, the backend preserves the existing payload. If payload is provided, it fully replaces the stored payload after backend validation.", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "Product detail section ID" }, + type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional section type" }, + order: { type: "number", description: "Optional target order" }, + payload: { type: "object", description: "Full replacement payload matching backend validation. If omitted, existing backend payload is preserved." }, + isActive: { type: "boolean", description: "Optional active state" }, + isLocked: { type: "boolean", description: "Optional locked state. Backend protects locked sections." } + }, + required: ["sectionId"] + } + }, + { + name: "reorder_product_detail_sections", + description: "Reorder product detail CMS sections. Backend prevents moving locked product-main sections out of first position.", + inputSchema: { + type: "object", + properties: { + productId: { type: "number", description: "Product ID" }, + sections: { + type: "array", + description: "Array of section order items, for example [{ \"id\": 10, \"order\": 1 }].", + items: { + type: "object", + properties: { + id: { type: "number", description: "Section ID" }, + order: { type: "number", description: "Target order starting at 1" } + }, + required: ["id", "order"] + } + } + }, + required: ["productId", "sections"] + } + }, + { + name: "delete_product_detail_section", + description: "Delete a product detail CMS section. Backend refuses deleting locked sections.", + inputSchema: { + type: "object", + properties: { + sectionId: { type: "number", description: "Product detail section ID" } + }, + required: ["sectionId"] + } } ] as const; @@ -521,12 +1303,91 @@ 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) { + // Global validation: most section tools require sectionId, but create/list/reorder helpers do not. + const toolsWithoutSectionId = new Set([ + "get_page_block_schema", + "validate_page_section_payload", + "create_page_with_section", + "add_page_section", + "reorder_page_sections", + "delete_dynamic_page", + "get_product_detail_sections", + "seed_product_detail_sections", + "add_product_detail_section", + "reorder_product_detail_sections", + ]); + if (!toolsWithoutSectionId.has(name) && params.sectionId === undefined) { throw new Error(`Validation Error: 'sectionId' is required for tool '${name}'`); } switch (name) { + case "get_page_block_schema": { + return { content: [{ type: "text", text: JSON.stringify(getPageBlockSchema(params.type), null, 2) }] }; + } + case "validate_page_section_payload": { + return { content: [{ type: "text", text: JSON.stringify(validatePayloadAgainstMetadata(params.type, params.payload), null, 2) }] }; + } + case "create_page_with_section": { + assertWebsitePageSectionType(params.type, params.allowProductNative === true); + const validation = validatePayloadAgainstMetadata(params.type, params.payload); + if (!validation.valid) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: false, + error: "Payload failed MCP preflight validation.", + validation, + }, null, 2) + }], + isError: true, + }; + } + + const atomicPayload = { + page: { + title: params.title, + slug: params.slug, + ...(params.isPublished !== undefined ? { isPublished: params.isPublished } : {}), + }, + section: { + type: params.type, + payload: params.payload, + isActive: params.isActive !== false, + ...(params.order !== undefined ? { order: params.order } : {}), + }, + }; + const result = await api.post("/pages/with-section", atomicPayload); + const pageId = result.data?.pageId ?? result.data?.page?.id; + if (!pageId) { + throw new Error("Backend did not return a pageId after atomically creating the page and section."); + } + + const preview = buildCmsPreview(params.slug, params.lang || defaultLang); + const response: Record = { + success: true, + tool: name, + pageId, + pageSlug: params.slug, + sectionId: result.data?.sectionId ?? result.data?.section?.id ?? null, + type: params.type, + preview, + warnings: buildRenderability(params.type).warnings, + }; + if (params.includeDebug === true) { + response.renderability = buildRenderability(params.type); + response.validation = validation; + response.page = result.data?.page; + response.section = result.data?.section; + } + + return { + content: [{ + type: "text", + text: JSON.stringify(response, null, 2) + }] + }; + } case "get_page_section": { const langParam = params.lang ? `?lang=${params.lang}` : ""; const res = await api.get(`/pages/sections/${params.sectionId}${langParam}`); @@ -536,6 +1397,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params if (params.pageId === undefined) { throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); } + assertWebsitePageSectionType(params.type, params.allowProductNative === true); const payload: any = { type: params.type, payload: params.payload, @@ -543,9 +1405,25 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params }; if (params.order !== undefined) payload.order = params.order; const res = await api.post(`/pages/${params.pageId}/sections`, payload); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + tool: name, + pageId: params.pageId, + pageSlug: params.pageSlug ?? undefined, + preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined, + renderability: buildRenderability(params.type), + section: res.data, + }, null, 2) + }] + }; } case "update_page_section": { + if (params.type) { + assertWebsitePageSectionType(params.type, params.allowProductNative === true); + } const payload: any = {}; if (params.type) payload.type = params.type; if (params.order !== undefined) payload.order = params.order; @@ -608,6 +1486,95 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params }] }; } + case "reorder_page_sections": { + if (params.pageId === undefined) { + throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); + } + const sectionOrders = normalizeSectionOrderItems(params); + const res = await api.put(`/pages/${params.pageId}/sections/reorder`, { sectionOrders }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: params.pageId, sectionOrders, result: res.data }, null, 2) }] }; + } + case "delete_dynamic_page": { + if (params.pageId === undefined) { + throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); + } + await api.delete(`/pages/${params.pageId}`); + return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: params.pageId }, null, 2) }] }; + } + case "get_product_detail_sections": { + if (params.productId === undefined) { + throw new Error(`Validation Error: 'productId' is required for tool '${name}'`); + } + const query = new URLSearchParams(); + if (params.includeInactive === true) query.set("includeInactive", "true"); + if (params.lang) query.set("lang", params.lang); + const suffix = query.toString() ? `?${query.toString()}` : ""; + const res = await api.get(`/products/${params.productId}/detail-sections${suffix}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "seed_product_detail_sections": { + if (params.productId === undefined) { + throw new Error(`Validation Error: 'productId' is required for tool '${name}'`); + } + const res = await api.post(`/products/${params.productId}/detail-sections/seed-default`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "add_product_detail_section": { + if (params.productId === undefined) { + throw new Error(`Validation Error: 'productId' is required for tool '${name}'`); + } + const payload: any = { + type: params.type, + payload: params.payload, + isActive: params.isActive !== false, + }; + if (params.order !== undefined) payload.order = params.order; + if (params.isLocked !== undefined) payload.isLocked = params.isLocked; + const res = await api.post(`/products/${params.productId}/detail-sections`, payload); + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + tool: name, + renderability: buildRenderability(params.type), + result: res.data, + }, null, 2) + }] + }; + } + case "update_product_detail_section": { + const payload: any = {}; + if (params.type) payload.type = params.type; + if (params.payload !== undefined) payload.payload = params.payload; + if (params.order !== undefined) payload.order = params.order; + if (params.isActive !== undefined) payload.isActive = params.isActive; + if (params.isLocked !== undefined) payload.isLocked = params.isLocked; + const res = await api.put(`/products/detail-sections/${params.sectionId}`, payload); + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + tool: name, + renderability: buildRenderability(res.data?.section?.type ?? params.type ?? "UNKNOWN"), + result: res.data, + }, null, 2) + }] + }; + } + case "reorder_product_detail_sections": { + if (params.productId === undefined) { + throw new Error(`Validation Error: 'productId' is required for tool '${name}'`); + } + const sections = normalizeSectionOrderItems(params); + const res = await api.put(`/products/${params.productId}/detail-sections/reorder`, { sections }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, productId: params.productId, sections, result: res.data }, null, 2) }] }; + } + case "delete_product_detail_section": { + await api.delete(`/products/detail-sections/${params.sectionId}`); + return { content: [{ type: "text", text: JSON.stringify({ success: true, sectionId: params.sectionId }, null, 2) }] }; + } case "add_accordion_category": case "update_accordion_category": case "add_accordion_question": diff --git a/apps/mcp-server/test_mcp_menu_challenger.ts b/apps/mcp-server/test_mcp_menu_challenger.ts index 3f15f76..560e6e0 100644 --- a/apps/mcp-server/test_mcp_menu_challenger.ts +++ b/apps/mcp-server/test_mcp_menu_challenger.ts @@ -23,7 +23,7 @@ async function test() { const waitResponse = async (ms: number) => { await new Promise(resolve => setTimeout(resolve, ms)); const lines = responseData.trim().split("\n"); - const lastLine = lines[lines.length - 1]; + const lastLine = lines[lines.length - 1] ?? ""; try { return JSON.parse(lastLine); } catch (e) { diff --git a/apps/website/src/app/[lang]/layout.tsx b/apps/website/src/app/[lang]/layout.tsx index d0e0e97..741dc40 100644 --- a/apps/website/src/app/[lang]/layout.tsx +++ b/apps/website/src/app/[lang]/layout.tsx @@ -160,7 +160,7 @@ export default async function RootLayout({ const themeStyle = buildThemeStyle(settings) return ( -
+
diff --git a/apps/website/src/app/globals.css b/apps/website/src/app/globals.css index 26f9ac1..1220426 100644 --- a/apps/website/src/app/globals.css +++ b/apps/website/src/app/globals.css @@ -60,43 +60,49 @@ @layer base { :root, html { - --font-body-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; - --font-display-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; + --font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; + --font-display-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; --font-serif-locale: ui-serif; } - html[lang="en"] { + [lang="en"] { --font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-body-locale); --font-serif-locale: ui-serif; } - html[lang="ja"] { + [lang="ja"] { --font-body-locale: "Hiragino Sans", "Yu Gothic", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-body-locale); --font-serif-locale: "Noto Serif JP", ui-serif; } - html[lang="zh"] { + [lang="zh"] { --font-body-locale: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-body-locale); --font-serif-locale: "Noto Serif SC", ui-serif; } - html[lang="ko"] { + [lang="ko"] { --font-body-locale: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-body-locale); --font-serif-locale: "Noto Serif KR", ui-serif; } - html[lang="vi"] { + [lang="vi"] { --font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-body-locale); --font-serif-locale: ui-serif; } html { - font-family: var(--font-body-locale); + font-family: var(--font-body-active, var(--font-body-locale)); + } + + .site-theme-root { + --font-sans: var(--font-body-active, var(--font-body-locale)); + --font-serif: var(--font-display-active, var(--font-display-locale)); + font-family: var(--font-sans); } body { @@ -108,7 +114,7 @@ h1, h2, h3, h4 { @apply text-text-main font-semibold tracking-tight; - font-family: var(--font-display-locale); + font-family: var(--font-display-active, var(--font-display-locale)); } } @@ -266,7 +272,7 @@ .ui-hero-title { color: var(--color-text-main); - font-family: var(--font-display-locale); + font-family: var(--font-display-active, var(--font-display-locale)); font-size: 3rem; font-weight: 300; line-height: 1.25; diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index 8c3f0df..926e372 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -26,7 +26,7 @@ const playfairDisplay = Playfair_Display({ export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/packages/types/index.ts b/packages/types/index.ts index 4ee4e8b..af2ef83 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -629,6 +629,34 @@ export const PAGE_SECTION_TYPES = [ ] as const; export type SectionType = typeof PAGE_SECTION_TYPES[number]; +export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record = { + HERO: HeroSectionPayloadSchema, + ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema, + NEWSLETTER: NewsletterSectionPayloadSchema, + EDITORIAL_TILE: EditorialTileSectionPayloadSchema, + EDITORIAL_CAROUSEL: EditorialCarouselSectionPayloadSchema, + PRODUCTS: ProductsSectionPayloadSchema, + CATEGORIES: CategoriesSectionPayloadSchema, + TEXT: TextSectionPayloadSchema, + BANNER: BannerSectionPayloadSchema, + HERO_ARTICLE: HeroArticleSectionPayloadSchema, + RICH_TEXT: RichTextSectionPayloadSchema, + SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema, + QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema, + PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema, + ACCORDION: AccordionSectionPayloadSchema, + CONTACT_INFO: ContactInfoSectionPayloadSchema, + CONTACT_FORM: ContactFormSectionPayloadSchema, + HERO_V2: HeroV2SectionPayloadSchema, + USP_BAR: UspBarSectionPayloadSchema, + PRODUCT_MAIN: ProductLayoutSectionPayloadSchema, + PRODUCT_DETAILS: ProductLayoutSectionPayloadSchema, + PRODUCT_SPECS: ProductLayoutSectionPayloadSchema, + PRODUCT_REVIEWS: ProductLayoutSectionPayloadSchema, + RELATED_PRODUCTS: ProductLayoutSectionPayloadSchema, + CUSTOM: CustomSectionPayloadSchema, +}; + export const PRODUCT_DETAIL_SECTION_TYPES = [ 'PRODUCT_MAIN', 'HERO',