Files
homaroma/apps/mcp-server/section-toolkit.ts
T
2026-07-24 20:08:21 -04:00

1921 lines
92 KiB
TypeScript

import crypto from "crypto";
import type { AxiosInstance } from "axios";
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",
"ARTICLE_HEADER",
"ARTICLE_MEDIA",
"ARTICLE_GALLERY",
"ARTICLE_FACTS",
"ARTICLE_VIDEO",
"ARTICLE_REFERENCES",
"ARTICLE_TABLE",
"ARTICLE_DOWNLOAD",
"ARTICLE_CTA",
"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<string>(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: [
{ type: "string" },
{
type: "object",
additionalProperties: { type: "string" }
}
]
};
const localizedStringListSchema = {
type: "array",
items: localizedStringSchema
};
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
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<string, unknown>;
recommendedPayload: Record<string, unknown>;
};
const imageUrlExample = "https://elysia3051.kitizone.com/uploads/2b3cca4c-9f68-43c0-982b-097475f9e55f.jpeg";
const localizedObjectExample = { th: "ข้อความภาษาไทย", en: "English text" };
const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
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: "<p>นี่คือ TEXT block ที่สร้างผ่าน MCP</p>", en: "<p>This is a TEXT block created through MCP.</p>" },
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: "<p>สำรวจศิลปะแห่งกลิ่นหอมผ่านวัตถุดิบที่คัดสรร</p>",
en: "<p>Explore the art of fragrance through carefully selected ingredients.</p>",
},
},
},
ARTICLE_MEDIA: {
type: "ARTICLE_MEDIA",
requiredFields: ["body"],
optionalFields: ["heading", "imageUrl", "imageAlt", "imageCaption", "imagePosition", "width", "background"],
renderedFields: ["heading", "body", "imageUrl", "imageAlt", "imageCaption", "imagePosition", "width", "background"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: [
"body is rendered as plain text with preserved line breaks; do not send HTML.",
"imageAlt is required whenever imageUrl is provided.",
],
minimalPayload: { body: localizedObjectExample },
recommendedPayload: {
heading: { th: "ความแม่นยำในทุกฟันสเตอร์", en: "Precision in every sprocket tooth" },
body: {
th: "Fine Blanking ช่วยควบคุมผิวตัดและรูปทรงของฟันสเตอร์ให้สม่ำเสมอ เพื่อการส่งกำลังที่ราบรื่นและแม่นยำ",
en: "Fine blanking controls the cut surface and tooth profile for smooth, precise power transmission.",
},
imageUrl: imageUrlExample,
imageAlt: { th: "สเตอร์ที่ผลิตด้วยเทคโนโลยี Fine Blanking", en: "A sprocket made with fine blanking technology" },
imageCaption: { th: "ผิวตัดเรียบและรูปทรงสม่ำเสมอ", en: "A smooth cut surface and consistent profile" },
imagePosition: "right",
width: "standard",
background: "default",
},
},
ARTICLE_HEADER: {
type: "ARTICLE_HEADER",
requiredFields: ["title"],
optionalFields: ["subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"],
renderedFields: ["title", "subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["imageAlt is required whenever imageUrl is provided."],
minimalPayload: { title: localizedObjectExample },
recommendedPayload: {
title: { th: "เทคโนโลยีที่อยู่เบื้องหลังทุกการขับเคลื่อน", en: "Technology behind every ride" },
subtitle: { th: "ทำความรู้จักกระบวนการผลิตที่ให้ความสำคัญกับความแม่นยำ", en: "A closer look at precision manufacturing." },
category: { th: "เทคโนโลยี", en: "Technology" },
author: { th: "ทีมงาน DDC", en: "DDC Team" },
publishDate: "2026-07-24",
},
},
ARTICLE_GALLERY: {
type: "ARTICLE_GALLERY",
requiredFields: ["images", "images[].imageUrl", "images[].alt"],
optionalFields: ["title", "layout", "images[].caption"],
renderedFields: ["title", "layout", "images"],
storedOnlyFields: [],
discoveryToolsNeeded: ["Use real CMS media URLs; do not invent image URLs."],
warnings: ["Provide 2 to 8 images. Every image requires localized alt text."],
minimalPayload: {
images: [
{ imageUrl: imageUrlExample, alt: localizedObjectExample },
{ imageUrl: imageUrlExample, alt: localizedObjectExample },
],
},
recommendedPayload: {
title: { th: "ภาพจากกระบวนการผลิต", en: "Inside the production process" },
layout: "grid",
images: [
{ imageUrl: imageUrlExample, alt: { th: "ขั้นตอนการผลิตชิ้นงาน", en: "Component production" } },
{ imageUrl: imageUrlExample, alt: { th: "การตรวจสอบคุณภาพ", en: "Quality inspection" } },
],
},
},
ARTICLE_FACTS: {
type: "ARTICLE_FACTS",
requiredFields: ["items", "items[].title"],
optionalFields: ["title", "items[].description"],
renderedFields: ["title", "items"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Provide 1 to 8 concise facts."],
minimalPayload: { items: [{ title: localizedObjectExample }] },
recommendedPayload: {
title: { th: "ข้อมูลสำคัญ", en: "Key facts" },
items: [
{ title: { th: "ผิวตัดสม่ำเสมอ", en: "Consistent cut surface" }, description: { th: "ช่วยลดขั้นตอนการแต่งผิว", en: "Reduces secondary finishing." } },
{ title: { th: "รูปทรงแม่นยำ", en: "Precise geometry" }, description: { th: "ช่วยให้การขบกับโซ่ต่อเนื่อง", en: "Supports consistent chain engagement." } },
],
},
},
ARTICLE_VIDEO: {
type: "ARTICLE_VIDEO",
requiredFields: ["videoUrl"],
optionalFields: ["title", "caption", "posterUrl", "aspectRatio"],
renderedFields: ["title", "videoUrl", "caption", "posterUrl", "aspectRatio"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use a full HTTPS YouTube, Vimeo, or direct video URL. Do not send embed HTML."],
minimalPayload: { videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID" },
recommendedPayload: {
title: { th: "ชมกระบวนการผลิต", en: "Watch the production process" },
videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID",
caption: { th: "เบื้องหลังการผลิตที่แม่นยำ", en: "Behind precision manufacturing" },
aspectRatio: "16:9",
},
},
ARTICLE_REFERENCES: {
type: "ARTICLE_REFERENCES",
requiredFields: ["items", "items[].title"],
optionalFields: ["title", "items[].url", "items[].note"],
renderedFields: ["title", "items"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use verified source URLs only. A reference may omit url for offline sources."],
minimalPayload: { items: [{ title: localizedObjectExample }] },
recommendedPayload: {
title: { th: "แหล่งข้อมูล", en: "References" },
items: [{ title: { th: "เอกสารข้อมูลทางเทคนิค", en: "Technical documentation" }, note: { th: "ข้อมูลจากฝ่ายวิศวกรรม", en: "Engineering source material" } }],
},
},
ARTICLE_TABLE: {
type: "ARTICLE_TABLE",
requiredFields: ["columns", "rows", "rows[].label", "rows[].cells"],
optionalFields: ["title", "note"],
renderedFields: ["title", "columns", "rows", "note"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use 2 to 6 columns and 1 to 20 rows. Every row must contain exactly one cell per column."],
minimalPayload: {
columns: [{ th: "หัวข้อ", en: "Item" }, { th: "รายละเอียด", en: "Details" }],
rows: [{ label: localizedObjectExample, cells: [localizedObjectExample, localizedObjectExample] }],
},
recommendedPayload: {
title: { th: "ข้อมูลเปรียบเทียบ", en: "Comparison" },
columns: [{ th: "คุณสมบัติ", en: "Property" }, { th: "Fine Blanking", en: "Fine blanking" }],
rows: [{ label: { th: "ผิวตัด", en: "Cut surface" }, cells: [{ th: "ผิวตัด", en: "Cut surface" }, { th: "เรียบและสม่ำเสมอ", en: "Smooth and consistent" }] }],
},
},
ARTICLE_DOWNLOAD: {
type: "ARTICLE_DOWNLOAD",
requiredFields: ["files", "files[].title", "files[].url"],
optionalFields: ["title", "description", "files[].fileType", "files[].fileSize"],
renderedFields: ["title", "description", "files"],
storedOnlyFields: [],
discoveryToolsNeeded: ["Use verified HTTPS document URLs from the CMS media library."],
warnings: ["Provide 1 to 10 files. File URLs must use HTTPS."],
minimalPayload: { files: [{ title: localizedObjectExample, url: "https://example.com/document.pdf" }] },
recommendedPayload: {
title: { th: "ดาวน์โหลดเอกสาร", en: "Downloads" },
files: [{ title: { th: "ข้อมูลผลิตภัณฑ์", en: "Product specification" }, url: "https://example.com/spec.pdf", fileType: "PDF" }],
},
},
ARTICLE_CTA: {
type: "ARTICLE_CTA",
requiredFields: ["title", "primaryText", "primaryLink"],
optionalFields: ["description", "secondaryText", "secondaryLink", "alignment"],
renderedFields: ["title", "description", "primaryText", "primaryLink", "secondaryText", "secondaryLink", "alignment"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Links must be website-relative paths or HTTPS URLs. secondaryLink is required with secondaryText."],
minimalPayload: { title: localizedObjectExample, primaryText: localizedObjectExample, primaryLink: "/contact" },
recommendedPayload: {
title: { th: "ต้องการข้อมูลเพิ่มเติม?", en: "Need more information?" },
description: { th: "ติดต่อทีมงานเพื่อสอบถามรายละเอียดผลิตภัณฑ์", en: "Contact our team for product details." },
primaryText: { th: "ติดต่อเรา", en: "Contact us" },
primaryLink: "/contact",
alignment: "center",
},
},
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<string, { required: string[]; example: string }> = {
HERO: {
required: ["tagline", "title", "description", "imageUrl", "buttonText", "buttonLink"],
example: `{ "tagline": ${localizedStringExample}, "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/hero.jpg", "buttonText": ${localizedStringExample}, "buttonLink": "/shop" }`
},
ANNOUNCEMENT_BAR: {
required: ["slides"],
example: `{ "slides": [${localizedStringExample}] }`
},
NEWSLETTER: {
required: ["title", "description", "buttonText", "showPrivacyCheckbox"],
example: `{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "buttonText": ${localizedStringExample}, "showPrivacyCheckbox": true }`
},
EDITORIAL_TILE: {
required: ["title", "description", "imageUrl", "imagePosition"],
example: `{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/tile.jpg", "imagePosition": "left" }`
},
EDITORIAL_CAROUSEL: {
required: ["title", "slides[].category", "slides[].title", "slides[].description", "slides[].imageUrl"],
example: `{ "title": ${localizedStringExample}, "slides": [ { "category": ${localizedStringExample}, "title": ${localizedStringExample}, "description": ${localizedStringExample}, "imageUrl": "https://example.com/slide.jpg" } ] }`
},
PRODUCTS: {
required: ["title", "type"],
example: `{ "title": ${localizedStringExample}, "type": "custom", "productIds": ${productIdListExample}, "limit": 4 }`
},
CATEGORIES: {
required: ["title"],
example: `{ "title": ${localizedStringExample}, "categoryIds": ${categoryIdListExample} }`
},
TEXT: {
required: ["content"],
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
},
BANNER: {
required: ["imageUrl"],
example: `{ "imageUrl": "https://example.com/banner.jpg", "title": ${localizedStringExample}, "buttonText": ${localizedStringExample}, "focalPoint": { "x": "50%", "y": "50%" } }`
},
HERO_ARTICLE: {
required: ["title"],
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg" }`
},
RICH_TEXT: {
required: ["content"],
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
},
ARTICLE_MEDIA: {
required: ["body"],
example: `{ "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`
},
ARTICLE_HEADER: {
required: ["title"],
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample} }`
},
ARTICLE_GALLERY: {
required: ["images", "images[].imageUrl", "images[].alt"],
example: `{ "images": [{ "imageUrl": "https://example.com/one.jpg", "alt": ${localizedStringExample} }, { "imageUrl": "https://example.com/two.jpg", "alt": ${localizedStringExample} }] }`
},
ARTICLE_FACTS: {
required: ["items", "items[].title"],
example: `{ "title": ${localizedStringExample}, "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample} }] }`
},
ARTICLE_VIDEO: {
required: ["videoUrl"],
example: `{ "videoUrl": "https://www.youtube.com/watch?v=VIDEO_ID", "title": ${localizedStringExample}, "aspectRatio": "16:9" }`
},
ARTICLE_REFERENCES: {
required: ["items", "items[].title"],
example: `{ "items": [{ "title": ${localizedStringExample}, "url": "https://example.com/source" }] }`
},
ARTICLE_TABLE: {
required: ["columns", "rows", "rows[].label", "rows[].cells"],
example: `{ "columns": [${localizedStringExample}, ${localizedStringExample}], "rows": [{ "label": ${localizedStringExample}, "cells": [${localizedStringExample}, ${localizedStringExample}] }] }`
},
ARTICLE_DOWNLOAD: {
required: ["files", "files[].title", "files[].url"],
example: `{ "files": [{ "title": ${localizedStringExample}, "url": "https://example.com/document.pdf", "fileType": "PDF" }] }`
},
ARTICLE_CTA: {
required: ["title", "primaryText", "primaryLink"],
example: `{ "title": ${localizedStringExample}, "primaryText": ${localizedStringExample}, "primaryLink": "/contact", "alignment": "center" }`
},
SCENT_NOTES_BREAKDOWN: {
required: [],
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
},
QUOTE_CALLOUT: {
required: ["quote"],
example: `{ "quote": ${localizedStringExample}, "author": ${localizedStringExample} }`
},
PRODUCT_CAROUSEL: {
required: ["type"],
example: `{ "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 4 }`
},
PRODUCT_MAIN: {
required: [],
example: `{ }`
},
PRODUCT_DETAILS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
},
PRODUCT_SPECS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true }`
},
RELATED_PRODUCTS: {
required: [],
example: `{ "title": ${localizedStringExample}, "showHeading": true, "showViewAllLink": true }`
},
ACCORDION: {
required: [],
example: `{ "title": ${localizedStringExample}, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`
},
CONTACT_INFO: {
required: ["address", "email", "line", "hours"],
example: `{ "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`
},
CONTACT_FORM: {
required: [],
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`
},
HERO_V2: {
required: ["title", "imageUrl"],
example: `{ "title": ${localizedStringExample}, "imageUrl": "${imageUrlExample}", "contentAlignment": "left", "primaryButtonText": ${localizedStringExample}, "primaryButtonLink": "/shop" }`
},
USP_BAR: {
required: ["items", "items[].icon", "items[].title"],
example: `{ "items": [{ "icon": "leaf", "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }] }`
},
CUSTOM: {
required: ["componentId"],
example: `BakeryCategories: { "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop", "icon": "bakery" }] } }. StoryFeatureMosaic: { "componentId": "StoryFeatureMosaic", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "description": ${localizedStringExample}, "buttonText": ${localizedStringExample}, "buttonHref": "/about", "images": [{ "imageUrl": "https://...", "alt": ${localizedStringExample} }], "features": [{ "icon": "leaf", "title": ${localizedStringExample} }] } }`
}
};
function getRenderTarget(sectionType: string) {
const normalizedType = sectionType.toUpperCase();
if (WEBSITE_PAGE_SECTION_TYPES.has(normalizedType)) return "website-page";
if (PRODUCT_DETAIL_NATIVE_SECTION_TYPES.has(normalizedType)) return "product-detail";
return "unsupported";
}
function buildRenderability(sectionType: string) {
const normalizedType = sectionType.toUpperCase();
const renderTarget = getRenderTarget(normalizedType);
const metadata = PAGE_BLOCK_METADATA[normalizedType];
return {
renderTarget,
websitePageRenderable: renderTarget === "website-page",
productDetailRenderable: renderTarget === "product-detail" || renderTarget === "website-page",
renderedFields: metadata?.renderedFields ?? undefined,
storedOnlyFields: metadata?.storedOnlyFields ?? undefined,
warnings: metadata?.warnings ?? [],
note:
renderTarget === "product-detail"
? `${normalizedType} is a product detail native block. It is rendered by product detail pages only, not by generic CMS pages. Use product detail CMS tools for this block.`
: renderTarget === "website-page"
? `${normalizedType} is renderable by apps/website DynamicPageRenderer on generic CMS pages.`
: `${normalizedType} is not mapped by the website renderer.`,
};
}
function assertWebsitePageSectionType(sectionType: string, allowProductNative = false) {
const normalizedType = sectionType.toUpperCase();
if (allowProductNative || WEBSITE_PAGE_SECTION_TYPES.has(normalizedType)) return;
if (PRODUCT_DETAIL_NATIVE_SECTION_TYPES.has(normalizedType)) {
throw new Error(
`Render Target Error: ${normalizedType} is a product detail native block and will not render on generic CMS pages. Use add_product_detail_section/update_product_detail_section instead, or pass allowProductNative=true if you intentionally want to store a non-rendering legacy page section.`
);
}
throw new Error(`Render Target Error: ${normalizedType} is not supported by the website page renderer.`);
}
export function buildCmsPreview(slug: string, lang = defaultLang) {
const cleanSlug = String(slug || "").replace(/^\/+|\/+$/g, "");
const previewPath = cleanSlug === "home" ? `/${lang}` : `/${lang}/${cleanSlug}`;
const websiteBaseUrl = (process.env.MCP_WEBSITE_BASE_URL || process.env.NEXT_PUBLIC_SITE_URL || "").replace(/\/$/, "");
return {
lang,
previewPath,
previewUrl: websiteBaseUrl ? `${websiteBaseUrl}${previewPath}` : previewPath,
};
}
export function getPageBlockSchema(sectionType: string) {
const normalizedType = sectionType.toUpperCase();
const metadata = PAGE_BLOCK_METADATA[normalizedType];
const guide = ADD_PAGE_SECTION_GUIDANCE[normalizedType] ?? { required: [], example: "{}" };
return {
type: normalizedType,
renderability: buildRenderability(normalizedType),
requiredFields: metadata?.requiredFields ?? guide.required,
optionalFields: metadata?.optionalFields ?? [],
renderedFields: metadata?.renderedFields ?? [],
storedOnlyFields: metadata?.storedOnlyFields ?? [],
discoveryToolsNeeded: metadata?.discoveryToolsNeeded ?? [],
warnings: metadata?.warnings ?? [],
minimalPayload: metadata?.minimalPayload ?? null,
recommendedPayload: metadata?.recommendedPayload ?? null,
legacyExample: guide.example,
};
}
function hasRenderableValue(value: any) {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim().length > 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] ?? {
required: [],
example: `{ }`
};
return `- ${type}: required ${guide.required.length > 0 ? guide.required.join(", ") : "no always-required payload fields"}. Minimal example ${guide.example}`;
}).join("\n");
return `Add a block section to a dynamic page layout. The payload is fully replaced on create and must satisfy the exact schema for the chosen section type. Use the following required fields and minimal examples for first-time use:\n${toolSummaries}`;
}
const SECTION_TOOL_CONFIGS = [
{
name: "update_hero_article",
sectionType: "HERO_ARTICLE",
description: `Update a HERO_ARTICLE section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
publishDate: { type: "string", description: "Plain string publish date or display date" },
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
imageUrl: { type: "string", description: "Plain string main image URL" },
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
},
required: ["sectionId"]
},
{
name: "update_editorial_tile",
sectionType: "EDITORIAL_TILE",
description: `Update an EDITORIAL_TILE section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. 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",
description: `Update a RICH_TEXT section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
},
required: ["sectionId", "content"]
},
{
name: "update_article_media",
sectionType: "ARTICLE_MEDIA",
description: `Update an ARTICLE_MEDIA section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. body is localized plain text; do not send HTML. imageAlt is required when imageUrl is present. Example payload: { "sectionId": 35, "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://...", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
heading: { ...localizedStringSchema, description: `Optional localized heading, for example ${localizedStringExample}` },
body: { ...localizedStringSchema, description: `Localized plain text body, for example ${localizedStringExample}` },
imageUrl: { type: "string", description: "Optional image URL or website-relative media path" },
imageAlt: { ...localizedStringSchema, description: `Localized image alternative text, required with imageUrl` },
imageCaption: { ...localizedStringSchema, description: `Optional localized image caption` },
imagePosition: { type: "string", enum: ["left", "right", "top"], description: "Responsive image placement" },
width: { type: "string", enum: ["narrow", "standard", "wide"], description: "Content width preset" },
background: { type: "string", enum: ["default", "muted"], description: "Theme-aware background preset" },
},
required: ["sectionId"]
},
{
name: "update_scent_notes_breakdown",
sectionType: "SCENT_NOTES_BREAKDOWN",
description: `Update a SCENT_NOTES_BREAKDOWN section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
},
required: ["sectionId"]
},
{
name: "update_quote_callout",
sectionType: "QUOTE_CALLOUT",
description: `Update a QUOTE_CALLOUT section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
},
required: ["sectionId"]
},
{
name: "update_product_carousel",
sectionType: "PRODUCT_CAROUSEL",
description: `Update a PRODUCT_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. Title and description are localized. type is required for a valid stored payload. Use categoryId for featured carousels, brandId for brand carousels, or productIds for custom carousels. Example payload: { "sectionId": 52, "type": "custom", "productIds": ${productIdListExample}, "title": ${localizedStringExample}, "limit": 6, "showPrice": true }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
type: { type: "string", enum: ["featured", "brand", "custom"], description: "Canonical carousel mode used for validation and product resolution" },
categoryId: { type: "number", description: "Category ID for featured carousels" },
brandId: { type: "number", description: "Brand ID for brand carousels" },
productIds: { type: "array", description: `Explicit product IDs for custom carousels, for example ${productIdListExample}`, items: { type: "number" } },
limit: { type: "number", description: "Maximum number of products to show" },
showPrice: { type: "boolean", description: "Show product price" },
showAddToCart: { type: "boolean", description: "Show add to cart button" },
products: { type: "array", description: "Optional explicit product list. Omit this to let backend resolve products by type/categoryId/brandId/productIds.", items: { type: "object" } },
includeHydratedProducts: { type: "boolean", description: "Optional. Set true to return hydrated products in the response. Default false for lightweight responses." }
},
required: ["sectionId"]
},
{
name: "update_accordion",
sectionType: "ACCORDION",
description: `Update an ACCORDION section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
backgroundColor: { type: "string", description: "Plain string representing a valid CSS color for the background" },
textColor: { type: "string", description: "Plain string representing a valid CSS color for text" },
iconColor: { type: "string", description: "Plain string representing a valid CSS color for icons" },
categories: {
type: "array",
description: "Accordion categories. IMPORTANT: Providing this will completely overwrite the existing categories array. Ensure you provide all categories.",
items: {
type: "object",
properties: {
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
icon: { type: "string", description: "Optional icon or emoji" },
questions: {
type: "array",
description: "Questions under the category",
items: {
type: "object",
properties: {
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
},
required: ["q", "a"]
}
}
},
required: ["name", "questions"]
}
}
},
required: ["sectionId"]
},
{
name: "update_contact_info",
sectionType: "CONTACT_INFO",
description: `Update a CONTACT_INFO section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
email: { type: "string", description: "Plain string email address" },
line: { type: "string", description: "Plain string Line ID or contact" },
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
},
required: ["sectionId", "address", "email", "line", "hours"]
},
{
name: "update_contact_form",
sectionType: "CONTACT_FORM",
description: `Update a CONTACT_FORM section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
},
required: ["sectionId"]
},
{
name: "update_editorial_carousel",
sectionType: "EDITORIAL_CAROUSEL",
description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, layout, imagePosition, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "layout": "split_screen", "imagePosition": "left" }`,
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" },
layout: { type: "string", enum: ["standard", "split_screen"], description: "Layout style of the carousel (standard or split_screen)" },
imagePosition: { type: "string", enum: ["left", "right"], description: "Position of the image relative to text when layout is split_screen (left or right)" },
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;
type SectionToolConfig = (typeof SECTION_TOOL_CONFIGS)[number];
const SECTION_TOOL_CONFIG_BY_NAME = new Map<string, SectionToolConfig>(
SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const)
);
function buildSectionPayload(params: any, fields: readonly string[]) {
const payload: Record<string, any> = {};
for (const field of fields) {
if (params[field] !== undefined) {
payload[field] = params[field];
}
}
return payload;
}
function sanitizeSectionForResponse(sectionType: string, section: any, options?: { includeHydratedProducts?: boolean }) {
if (!section || !section.payload) return section;
if (sectionType === "PRODUCT_CAROUSEL" && !options?.includeHydratedProducts) {
return {
...section,
payload: {
...section.payload,
products: undefined,
}
};
}
return section;
}
function buildSectionUpdateResult(
toolName: string,
sectionType: string,
updatedSection: any,
payload: Record<string, any>,
options?: { includeHydratedProducts?: boolean }
) {
const notes: string[] = [];
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
}
if (sectionType === "PRODUCT_CAROUSEL" && !options?.includeHydratedProducts) {
notes.push("Hydrated products are omitted by default. Pass includeHydratedProducts=true to inspect resolved products.");
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
tool: toolName,
sectionType,
sectionId: updatedSection?.id,
updatedFields: Object.keys(payload),
appliedPayload: payload,
notes,
section: sanitizeSectionForResponse(sectionType, updatedSection, options)
},
null,
2
)
}
]
};
}
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 a new dynamic CMS page and its first website-renderable section atomically. This tool is create-only: if the slug already exists, use get_dynamic_page_detail to find the page ID, then use add_page_section.",
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.",
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 one website-renderable block to an existing dynamic CMS page. Call get_dynamic_page_detail to obtain pageId, then get_page_block_schema for the selected type before composing payload. Use product detail CMS tools for product-native blocks.",
inputSchema: {
type: "object",
properties: {
pageId: { type: "number", description: "The numeric ID of the page" },
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" },
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"]
}
},
{
name: "update_page_section",
description: "Update an existing block section payload, order, or active state. If 'payload' is provided here, it replaces the stored payload after backend validation. This tool does not merge payload keys. Use the type-specific update tools when you want MCP to merge a partial payload with the existing stored payload before writing.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
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" },
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"]
}
},
...SECTION_TOOL_CONFIGS.map((config) => ({
name: config.name,
description: config.description,
inputSchema: {
type: "object",
properties: config.properties,
required: config.required
}
})),
{
name: "add_accordion_category",
description: "Add a category to an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
name: { ...localizedStringSchema, description: "Localized category name" },
icon: { type: "string", description: "Optional plain string for icon/emoji" },
questions: {
type: "array",
description: "Optional array of questions to add",
items: {
type: "object",
properties: {
q: { ...localizedStringSchema, description: "Localized question text" },
a: { ...localizedStringSchema, description: "Localized answer text" }
},
required: ["q", "a"]
}
}
},
required: ["sectionId", "name"]
}
},
{
name: "update_accordion_category",
description: "Update a category in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category to update" },
name: { ...localizedStringSchema, description: "Optional localized category name" },
icon: { type: "string", description: "Optional plain string for icon/emoji" }
},
required: ["sectionId", "categoryId"]
}
},
{
name: "add_accordion_question",
description: "Add a question to a category in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category" },
question: { ...localizedStringSchema, description: "Localized question text" },
answer: { ...localizedStringSchema, description: "Localized answer text" }
},
required: ["sectionId", "categoryId", "question", "answer"]
}
},
{
name: "update_accordion_question",
description: "Update a question in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category" },
questionId: { type: "string", description: "ID of the question item to update" },
question: { ...localizedStringSchema, description: "Optional localized question text" },
answer: { ...localizedStringSchema, description: "Optional localized answer text" }
},
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",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
},
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;
export const SECTION_TOOL_NAMES = new Set<string>(SECTION_TOOL_DEFINITIONS.map((tool) => tool.name));
export async function handleSectionTool(api: AxiosInstance, name: string, params: any) {
if (!SECTION_TOOL_NAMES.has(name)) return null;
// 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<string, unknown> = {
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}`);
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}'`);
}
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 payload: any = {
type: params.type,
payload: params.payload,
isActive: params.isActive !== false
};
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({
success: true,
tool: name,
pageId: params.pageId,
pageSlug: params.pageSlug ?? undefined,
preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined,
sectionId: res.data?.sectionId ?? res.data?.section?.id ?? res.data?.id ?? null,
type: params.type,
order: res.data?.section?.order ?? res.data?.order ?? params.order ?? null,
warnings: buildRenderability(params.type).warnings,
}, 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;
if (params.payload) payload.payload = params.payload;
if (params.isActive !== undefined) payload.isActive = params.isActive;
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
return 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":
case "update_quote_callout":
case "update_product_carousel":
case "update_accordion":
case "update_contact_info":
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}`);
}
const payloadFields = Object.keys(config.properties).filter((key) => key !== "sectionId" && key !== "includeHydratedProducts");
const payload = buildSectionPayload(params, payloadFields);
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
payload.middleNotes = payload.heartNotes;
} else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) {
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}?hydrate=false`);
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: mergedPayload
});
return buildSectionUpdateResult(name, config.sectionType, res.data, payload, {
includeHydratedProducts: params.includeHydratedProducts === true
});
}
case "delete_page_section": {
await api.delete(`/pages/sections/${params.sectionId}`);
return {
content: [{
type: "text",
text: JSON.stringify({ success: true, sectionId: params.sectionId }, null, 2)
}]
};
}
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":
case "update_accordion_question": {
const existingRes = await api.get(`/pages/sections/${params.sectionId}?hydrate=false`);
const payload = existingRes.data?.payload || {};
payload.categories = payload.categories || [];
let responseBody: Record<string, any> = {
success: true,
tool: name,
sectionId: params.sectionId,
};
if (name === "add_accordion_category") {
const questions = params.questions?.map((q: any) => ({
id: crypto.randomUUID(),
q: q.q,
a: q.a
})) || [];
const category = {
id: crypto.randomUUID(),
name: params.name,
icon: params.icon,
questions
};
payload.categories.push(category);
responseBody = {
...responseBody,
categoryId: category.id,
category,
categoryCount: payload.categories.length,
};
} else if (name === "update_accordion_category") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
const changedFields: string[] = [];
if (params.name) {
cat.name = typeof params.name === "object" && typeof cat.name === "object"
? { ...cat.name, ...params.name }
: params.name;
changedFields.push("name");
}
if (params.icon !== undefined) {
cat.icon = params.icon;
changedFields.push("icon");
}
responseBody = {
...responseBody,
categoryId: cat.id,
changedFields,
category: cat,
};
} else if (name === "add_accordion_question") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
cat.questions = cat.questions || [];
const question = { id: crypto.randomUUID(), q: params.question, a: params.answer };
cat.questions.push(question);
responseBody = {
...responseBody,
categoryId: cat.id,
questionId: question.id,
question: question,
};
} else if (name === "update_accordion_question") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
cat.questions = cat.questions || [];
const item = cat.questions.find((q: any) => q.id === params.questionId);
if (!item) throw new Error(`Question with id ${params.questionId} not found`);
const changedFields: string[] = [];
if (params.question) {
item.q = typeof params.question === "object" && typeof item.q === "object"
? { ...item.q, ...params.question }
: params.question;
changedFields.push("question");
}
if (params.answer) {
item.a = typeof params.answer === "object" && typeof item.a === "object"
? { ...item.a, ...params.answer }
: params.answer;
changedFields.push("answer");
}
responseBody = {
...responseBody,
categoryId: cat.id,
questionId: item.id,
changedFields,
question: item,
};
}
await api.put(`/pages/sections/${params.sectionId}`, {
type: "ACCORDION",
payload
});
return { content: [{ type: "text", text: JSON.stringify(responseBody, null, 2) }] };
}
default:
return null;
}
}