This commit is contained in:
Your Name committed 2026-07-24 19:10:13 -04:00
1 parent bd8f5882e0
commit e509e3f0a2
8 files changed
+581 -12

No files matched your search

+30 -5
View File
@@ -17,6 +17,19 @@ This guide explains how to use the HomAroma MCP tools safely and consistently.
## Create Page Response Contract ## Create Page Response Contract
Use `create_page_with_section` only when the slug does not exist. It atomically
creates a new page and its first section; it does not update an existing page.
If it returns `PAGE_SLUG_EXISTS`, use the returned `pageId` with
`add_page_section`.
To add another block to an existing page:
1. Call `get_dynamic_page_detail` with the slug.
2. Call `get_page_block_schema` for the desired block type.
3. Call `add_page_section` with the returned page `id`.
4. Call `reorder_page_sections` if the new block must move relative to existing blocks.
5. Read the page back with `get_dynamic_page_detail`.
`create_dynamic_page` now returns a structured response that is easy for LLMs to parse: `create_dynamic_page` now returns a structured response that is easy for LLMs to parse:
- `success` - `success`
@@ -80,6 +93,16 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
- `content` is required and localized - `content` is required and localized
- HTML is allowed in `content` - HTML is allowed in `content`
### `ARTICLE_MEDIA`
- `body` is required, localized plain text; do not send HTML
- `heading`, `imageAlt`, and `imageCaption` are localized
- `imageUrl` is a plain URL or website-relative media path
- `imageAlt` is required whenever `imageUrl` is present
- `imagePosition` accepts `left`, `right`, or `top`
- `width` accepts `narrow`, `standard`, or `wide`
- `background` accepts `default` or `muted`
### `SCENT_NOTES_BREAKDOWN` ### `SCENT_NOTES_BREAKDOWN`
- `title` and `description` are localized - `title` and `description` are localized
@@ -120,11 +143,13 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
## Recommended Flow ## Recommended Flow
1. Find the page id from slug or metadata. 1. Determine whether the page already exists.
2. Create or update the page title translations. 2. For a new page, use `create_page_with_section` for the page and first block.
3. Add sections using the dedicated block tool when possible. 3. For an existing page, get its id and use `add_page_section`.
4. Read back the page with `get_dynamic_page_detail`. 4. Create or update the page title translations.
5. If a section has auto-populated fields, trust the read-back result over the raw update payload. 5. Update existing sections using the dedicated block tool when possible.
6. Read back the page with `get_dynamic_page_detail`.
7. If a section has auto-populated fields, trust the read-back result over the raw update payload.
## What to Expect ## What to Expect
@@ -0,0 +1,182 @@
import { Image as ImageIcon, UploadCloud } from 'lucide-react';
import { type PageSection } from '@homaroma/types';
interface ArticleMediaFormProps {
editingSection: Partial<PageSection>;
getLocalizedValue: (payload: unknown, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
updatePayloadValue: (field: string, value: string) => void;
setUploadTarget: (target: { type: 'main' | 'slide'; index?: number } | null) => void;
setIsMediaModalOpen: (open: boolean) => void;
}
const POSITION_OPTIONS = [
{ value: 'left', label: 'Image left' },
{ value: 'right', label: 'Image right' },
{ value: 'top', label: 'Image top' },
] as const;
export default function ArticleMediaForm({
editingSection,
getLocalizedValue,
updateLocalizedValue,
updatePayloadValue,
setUploadTarget,
setIsMediaModalOpen,
}: ArticleMediaFormProps) {
const payload = editingSection.payload || {};
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '';
const imagePosition = typeof payload.imagePosition === 'string' ? payload.imagePosition : 'right';
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Heading
</label>
<input
type="text"
value={getLocalizedValue(payload, 'heading')}
onChange={(event) => updateLocalizedValue('heading', event.target.value)}
className="admin-input w-full"
placeholder="Section heading"
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Paragraph
</label>
<textarea
required
rows={8}
value={getLocalizedValue(payload, 'body')}
onChange={(event) => updateLocalizedValue('body', event.target.value)}
className="admin-input w-full resize-y"
placeholder="Write one focused paragraph. Line breaks are preserved."
/>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Image
</label>
<button
type="button"
className="group relative flex h-48 w-full cursor-pointer flex-col items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-night-950/50 transition hover:border-cyan-glow/30"
onClick={() => {
setUploadTarget({ type: 'main' });
setIsMediaModalOpen(true);
}}
>
{imageUrl ? (
<>
<img src={imageUrl} className="h-full w-full object-cover" alt="" />
<span className="absolute inset-0 flex items-center justify-center bg-night-950/40 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
<UploadCloud className="h-10 w-10 text-white" />
</span>
</>
) : (
<span className="space-y-2 text-center">
<ImageIcon className="mx-auto h-10 w-10 text-slate-700" />
<span className="block text-xs font-bold uppercase tracking-widest text-slate-500">
Select from media library
</span>
</span>
)}
</button>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Image URL
</label>
<input
type="text"
value={imageUrl}
onChange={(event) => updatePayloadValue('imageUrl', event.target.value)}
className="admin-input w-full"
placeholder="/uploads/example.jpg"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Alternative text
</label>
<input
type="text"
required={Boolean(imageUrl)}
value={getLocalizedValue(payload, 'imageAlt')}
onChange={(event) => updateLocalizedValue('imageAlt', event.target.value)}
className="admin-input w-full"
placeholder="Describe the image"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Caption
</label>
<input
type="text"
value={getLocalizedValue(payload, 'imageCaption')}
onChange={(event) => updateLocalizedValue('imageCaption', event.target.value)}
className="admin-input w-full"
placeholder="Optional image caption"
/>
</div>
<fieldset className="space-y-2 md:col-span-2">
<legend className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Image position
</legend>
<div className="grid grid-cols-3 overflow-hidden rounded-lg border border-white/10">
{POSITION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => updatePayloadValue('imagePosition', option.value)}
className={`min-h-10 px-3 text-xs font-semibold transition ${
imagePosition === option.value
? 'bg-cyan-glow text-night-950'
: 'bg-night-950/40 text-slate-300 hover:bg-white/5'
}`}
>
{option.label}
</button>
))}
</div>
</fieldset>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Content width
</label>
<select
value={typeof payload.width === 'string' ? payload.width : 'standard'}
onChange={(event) => updatePayloadValue('width', event.target.value)}
className="admin-input w-full"
>
<option value="narrow">Narrow</option>
<option value="standard">Standard</option>
<option value="wide">Wide</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Background
</label>
<select
value={typeof payload.background === 'string' ? payload.background : 'default'}
onChange={(event) => updatePayloadValue('background', event.target.value)}
className="admin-input w-full"
>
<option value="default">Default</option>
<option value="muted">Muted</option>
</select>
</div>
</div>
);
}
+22
View File
@@ -30,6 +30,7 @@ import BannerForm from '../components/page-builder/BannerForm';
import TextForm from '../components/page-builder/TextForm'; import TextForm from '../components/page-builder/TextForm';
import HeroArticleForm from '../components/page-builder/HeroArticleForm'; import HeroArticleForm from '../components/page-builder/HeroArticleForm';
import RichTextForm from '../components/page-builder/RichTextForm'; import RichTextForm from '../components/page-builder/RichTextForm';
import ArticleMediaForm from '../components/page-builder/ArticleMediaForm';
import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm'; import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm';
import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm'; import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm';
import ProductCarouselForm from '../components/page-builder/ProductCarouselForm'; import ProductCarouselForm from '../components/page-builder/ProductCarouselForm';
@@ -632,6 +633,15 @@ export default function PageBuilderPage() {
content: '<p>Tell the story of your fragrance collection here.</p>', content: '<p>Tell the story of your fragrance collection here.</p>',
}; };
break; break;
case 'ARTICLE_MEDIA':
defaultPayload = {
heading: 'Article section',
body: 'Write one focused paragraph here.',
imagePosition: 'right',
width: 'standard',
background: 'default',
};
break;
case 'SCENT_NOTES_BREAKDOWN': case 'SCENT_NOTES_BREAKDOWN':
defaultPayload = { defaultPayload = {
title: 'Scent Notes', title: 'Scent Notes',
@@ -1074,6 +1084,7 @@ export default function PageBuilderPage() {
<option value="TEXT">TEXT (About / Brand story text)</option> <option value="TEXT">TEXT (About / Brand story text)</option>
<option value="HERO_ARTICLE">HERO ARTICLE (Editorial hero)</option> <option value="HERO_ARTICLE">HERO ARTICLE (Editorial hero)</option>
<option value="RICH_TEXT">RICH TEXT (Markdown / HTML block)</option> <option value="RICH_TEXT">RICH TEXT (Markdown / HTML block)</option>
<option value="ARTICLE_MEDIA">ARTICLE MEDIA (Paragraph with image)</option>
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option> <option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option> <option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option> <option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option>
@@ -1240,6 +1251,17 @@ export default function PageBuilderPage() {
/> />
)} )}
{editingSection.type?.toUpperCase() === 'ARTICLE_MEDIA' && (
<ArticleMediaForm
editingSection={editingSection}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && ( {editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && (
<ScentNotesBreakdownForm <ScentNotesBreakdownForm
editingSection={editingSection} editingSection={editingSection}
+84 -2
View File
@@ -13,6 +13,7 @@ import {
EditorialCarouselSectionPayloadSchema, EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema, HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema, RichTextSectionPayloadSchema,
ArticleMediaSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema, QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema, ProductCarouselSectionPayloadSchema,
@@ -239,6 +240,26 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}; };
} }
const existingPage = await prisma.page.findUnique({
where: { slug: pageInput.slug },
select: { id: true, slug: true },
});
if (existingPage) {
set.status = 409;
return {
success: false,
code: "PAGE_SLUG_EXISTS",
stage: "create_page",
error: "A page with this slug already exists",
diagnostic:
"The atomic endpoint creates new pages only. Add the section to the existing page instead.",
pageId: existingPage.id,
slug: existingPage.slug,
nextTool: "add_page_section",
partialWrite: false,
};
}
try { try {
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const page = await tx.page.create({ const page = await tx.page.create({
@@ -273,7 +294,14 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
set.status = 500; set.status = 500;
return { return {
success: false, success: false,
code: "PAGE_WITH_SECTION_CREATE_FAILED",
stage: "transaction",
error: "Failed to create page with section", error: "Failed to create page with section",
diagnostic:
err instanceof Error
? err.message
: "Unexpected error while creating the page and its first section.",
partialWrite: false,
}; };
} }
}, },
@@ -490,13 +518,46 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const pageId = Number(slug); const pageId = Number(slug);
const { type, order, payload, isActive } = body as any; const { type, order, payload, isActive } = body as any;
if (!Number.isInteger(pageId) || pageId <= 0) {
set.status = 400;
return {
success: false,
code: "INVALID_PAGE_ID",
stage: "validate_page",
error: "Invalid page ID",
pageId: slug,
partialWrite: false,
};
}
const page = await prisma.page.findUnique({
where: { id: pageId },
select: { id: true, slug: true },
});
if (!page) {
set.status = 404;
return {
success: false,
code: "PAGE_NOT_FOUND",
stage: "find_page",
error: "Page not found",
pageId,
partialWrite: false,
};
}
const validationResult = validateSectionPayload(type, payload); const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) { if (!validationResult.success) {
set.status = 400; set.status = 400;
return { return {
success: false,
code: "SECTION_PAYLOAD_INVALID",
stage: "validate_section",
error: "Validation failed", error: "Validation failed",
details: validationResult.issues, details: validationResult.issues,
validationIssues: summarizeValidationIssues(validationResult.issues || []), validationIssues: summarizeValidationIssues(validationResult.issues || []),
pageId,
partialWrite: false,
}; };
} }
@@ -509,7 +570,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
finalOrder = lastSection ? lastSection.order + 1 : 1; finalOrder = lastSection ? lastSection.order + 1 : 1;
} }
return await prisma.pageSection.create({ const section = await prisma.pageSection.create({
data: { data: {
pageId, pageId,
type: type.toUpperCase(), type: type.toUpperCase(),
@@ -518,10 +579,28 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
isActive: isActive !== false, isActive: isActive !== false,
}, },
}); });
return {
success: true,
pageId,
pageSlug: page.slug,
sectionId: section.id,
section,
};
} catch (err) { } catch (err) {
console.error("[PageSections] Error:", err); console.error("[PageSections] Error:", err);
set.status = 500; set.status = 500;
return { error: "Failed to add section" }; return {
success: false,
code: "SECTION_CREATE_FAILED",
stage: "create_section",
error: "Failed to add section",
diagnostic:
err instanceof Error
? err.message
: "Unexpected error while adding the section.",
pageId: Number(slug),
partialWrite: false,
};
} }
}, },
{ {
@@ -693,6 +772,9 @@ export function validateSectionPayload(
case "RICH_TEXT": case "RICH_TEXT":
schema = RichTextSectionPayloadSchema; schema = RichTextSectionPayloadSchema;
break; break;
case "ARTICLE_MEDIA":
schema = ArticleMediaSectionPayloadSchema;
break;
case "SCENT_NOTES_BREAKDOWN": case "SCENT_NOTES_BREAKDOWN":
schema = ScentNotesBreakdownSectionPayloadSchema; schema = ScentNotesBreakdownSectionPayloadSchema;
break; break;
+89
View File
@@ -17,6 +17,7 @@ const websiteBlockTypes = [
"BANNER", "BANNER",
"HERO_ARTICLE", "HERO_ARTICLE",
"RICH_TEXT", "RICH_TEXT",
"ARTICLE_MEDIA",
"SCENT_NOTES_BREAKDOWN", "SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT", "QUOTE_CALLOUT",
"PRODUCT_CAROUSEL", "PRODUCT_CAROUSEL",
@@ -67,6 +68,20 @@ describe("Page Builder block contracts", () => {
}); });
}); });
test("ARTICLE_MEDIA requires imageAlt when imageUrl is provided", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_MEDIA", {
body: { th: "ข้อความภาษาไทย" },
imageUrl: "/uploads/article.jpg",
});
expect(result.valid).toBe(false);
expect(result.validationIssues).toContainEqual({
path: "imageAlt",
message: "imageAlt is required when imageUrl is provided",
code: "custom",
});
});
test("create_page_with_section rejects invalid payloads without calling backend", async () => { test("create_page_with_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0; let postCalls = 0;
const api = { const api = {
@@ -133,4 +148,78 @@ describe("Page Builder block contracts", () => {
], ],
}); });
}); });
test("add_page_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, "add_page_section", {
pageId: 15,
type: "HERO_V2",
payload: {},
});
expect(result && "isError" in result ? result.isError : false).toBe(true);
expect(postCalls).toBe(0);
});
test("add_page_section uses the existing-page endpoint 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: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
sectionId: 36,
section: { id: 36, type: "HERO_ARTICLE", order: 2 },
},
};
},
} as any;
const payload = getPageBlockSchema("HERO_ARTICLE").recommendedPayload;
const result = await handleSectionTool(api, "add_page_section", {
pageId: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
type: "HERO_ARTICLE",
payload,
order: 2,
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toEqual([{
url: "/pages/15/sections",
payload: {
type: "HERO_ARTICLE",
payload,
isActive: true,
order: 2,
},
}]);
expect(response).toEqual({
success: true,
tool: "add_page_section",
pageId: 15,
pageSlug: "fine-blanking-ddc-3-in-1",
preview: {
lang: "th",
previewPath: "/th/fine-blanking-ddc-3-in-1",
previewUrl: "/th/fine-blanking-ddc-3-in-1",
},
sectionId: 36,
type: "HERO_ARTICLE",
order: 2,
warnings: [
"overlayColor is accepted by backend schema but is not used by apps/website HeroArticleSection today.",
],
});
});
}); });
+68 -4
View File
@@ -18,6 +18,7 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
"BANNER", "BANNER",
"HERO_ARTICLE", "HERO_ARTICLE",
"RICH_TEXT", "RICH_TEXT",
"ARTICLE_MEDIA",
"SCENT_NOTES_BREAKDOWN", "SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT", "QUOTE_CALLOUT",
"PRODUCT_CAROUSEL", "PRODUCT_CAROUSEL",
@@ -299,6 +300,32 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
}, },
}, },
}, },
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",
},
},
SCENT_NOTES_BREAKDOWN: { SCENT_NOTES_BREAKDOWN: {
type: "SCENT_NOTES_BREAKDOWN", type: "SCENT_NOTES_BREAKDOWN",
requiredFields: [], requiredFields: [],
@@ -524,6 +551,10 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
required: ["content"], required: ["content"],
example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }` example: `{ "title": ${localizedStringExample}, "content": ${localizedStringExample} }`
}, },
ARTICLE_MEDIA: {
required: ["body"],
example: `{ "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`
},
SCENT_NOTES_BREAKDOWN: { SCENT_NOTES_BREAKDOWN: {
required: [], required: [],
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }` example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
@@ -783,6 +814,23 @@ const SECTION_TOOL_CONFIGS = [
}, },
required: ["sectionId", "content"] 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", name: "update_scent_notes_breakdown",
sectionType: "SCENT_NOTES_BREAKDOWN", sectionType: "SCENT_NOTES_BREAKDOWN",
@@ -1020,7 +1068,7 @@ export const SECTION_TOOL_DEFINITIONS = [
}, },
{ {
name: "create_page_with_section", 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.", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -1052,7 +1100,7 @@ export const SECTION_TOOL_DEFINITIONS = [
}, },
{ {
name: "add_page_section", name: "add_page_section",
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.`, description: `Add a new website-renderable block to an existing dynamic CMS page. Use this instead of create_page_with_section when the page or slug already exists. Call get_dynamic_page_detail first to obtain pageId, and get_page_block_schema before composing payload.\n\n${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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -1398,6 +1446,20 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`); throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
} }
assertWebsitePageSectionType(params.type, params.allowProductNative === true); 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 = { const payload: any = {
type: params.type, type: params.type,
payload: params.payload, payload: params.payload,
@@ -1414,8 +1476,10 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
pageId: params.pageId, pageId: params.pageId,
pageSlug: params.pageSlug ?? undefined, pageSlug: params.pageSlug ?? undefined,
preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined, preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined,
renderability: buildRenderability(params.type), sectionId: res.data?.sectionId ?? res.data?.section?.id ?? res.data?.id ?? null,
section: res.data, type: params.type,
order: res.data?.section?.order ?? res.data?.order ?? params.order ?? null,
warnings: buildRenderability(params.type).warnings,
}, null, 2) }, null, 2)
}] }]
}; };
@@ -469,6 +469,84 @@ function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang
) )
} }
function ArticleMediaSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const heading = localizedFromRecord(payload, 'heading', resolvedLang)
const body = localizedFromRecord(payload, 'body', resolvedLang)
const imageAlt = localizedFromRecord(payload, 'imageAlt', resolvedLang)
const imageCaption = localizedFromRecord(payload, 'imageCaption', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'left' || payload.imagePosition === 'top'
? payload.imagePosition
: 'right'
const widthClass = payload.width === 'narrow'
? 'max-w-3xl'
: payload.width === 'wide'
? 'max-w-7xl'
: 'max-w-6xl'
const backgroundClass = payload.background === 'muted'
? 'bg-surface-card'
: 'bg-surface-page'
if (!body) return null
const copy = (
<div className="flex min-w-0 flex-col justify-center">
{heading && (
<h2 className="mb-5 text-3xl font-semibold text-text-main md:text-4xl">
{heading}
</h2>
)}
<p className="whitespace-pre-line text-base leading-8 text-muted-text md:text-lg">
{body}
</p>
</div>
)
const media = imageUrl ? (
<figure className="min-w-0">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-lg bg-surface-page">
<Image
src={imageUrl}
alt={imageAlt || heading || ''}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
{imageCaption && (
<figcaption className="mt-3 text-sm leading-6 text-muted-text">
{imageCaption}
</figcaption>
)}
</figure>
) : null
return (
<section className={`${backgroundClass} ui-section-band`}>
<div className={`mx-auto ${widthClass}`}>
{!media ? copy : imagePosition === 'top' ? (
<div className="space-y-8">
{media}
{copy}
</div>
) : (
<div className="grid items-center gap-8 md:grid-cols-2 md:gap-12">
<div className={imagePosition === 'right' ? 'md:order-2' : undefined}>
{media}
</div>
<div className={imagePosition === 'right' ? 'md:order-1' : undefined}>
{copy}
</div>
</div>
)}
</div>
</section>
)
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang() const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1106,6 +1184,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<TextSection payload={payload} lang={lang} /> <TextSection payload={payload} lang={lang} />
</ScrollReveal> </ScrollReveal>
) )
case 'ARTICLE_MEDIA':
return (
<ScrollReveal key={section.id}>
<ArticleMediaSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'SCENT_NOTES_BREAKDOWN': case 'SCENT_NOTES_BREAKDOWN':
return ( return (
<ScrollReveal key={section.id}> <ScrollReveal key={section.id}>
+22 -1
View File
@@ -444,6 +444,26 @@ export const RichTextSectionPayloadSchema = z.object({
}); });
export type RichTextSectionPayload = z.infer<typeof RichTextSectionPayloadSchema>; export type RichTextSectionPayload = z.infer<typeof RichTextSectionPayloadSchema>;
export const ArticleMediaSectionPayloadSchema = z.object({
heading: LocalizedStringSchema.optional(),
body: LocalizedStringSchema,
imageUrl: z.string().min(1).optional(),
imageAlt: LocalizedStringSchema.optional(),
imageCaption: LocalizedStringSchema.optional(),
imagePosition: z.enum(['left', 'right', 'top']).optional().default('right'),
width: z.enum(['narrow', 'standard', 'wide']).optional().default('standard'),
background: z.enum(['default', 'muted']).optional().default('default'),
}).strict().superRefine((payload, ctx) => {
if (payload.imageUrl && !payload.imageAlt) {
ctx.addIssue({
code: 'custom',
path: ['imageAlt'],
message: 'imageAlt is required when imageUrl is provided',
});
}
});
export type ArticleMediaSectionPayload = z.infer<typeof ArticleMediaSectionPayloadSchema>;
export const ScentNotesBreakdownSectionPayloadSchema = z.object({ export const ScentNotesBreakdownSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(), description: LocalizedStringSchema.optional(),
@@ -622,7 +642,7 @@ export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [ export const PAGE_SECTION_TYPES = [
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER', 'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT', 'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_MEDIA', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM', 'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS', 'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM' 'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
@@ -641,6 +661,7 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
BANNER: BannerSectionPayloadSchema, BANNER: BannerSectionPayloadSchema,
HERO_ARTICLE: HeroArticleSectionPayloadSchema, HERO_ARTICLE: HeroArticleSectionPayloadSchema,
RICH_TEXT: RichTextSectionPayloadSchema, RICH_TEXT: RichTextSectionPayloadSchema,
ARTICLE_MEDIA: ArticleMediaSectionPayloadSchema,
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema, SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema, QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema, PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,