x
This commit is contained in:
1 parent
bd8f5882e0
commit
e509e3f0a2
8 files changed
+581
-12
No files matched your search
+30
-5
@@ -17,6 +17,19 @@ This guide explains how to use the HomAroma MCP tools safely and consistently.
|
||||
|
||||
## 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:
|
||||
|
||||
- `success`
|
||||
@@ -80,6 +93,16 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
|
||||
- `content` is required and localized
|
||||
- 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`
|
||||
|
||||
- `title` and `description` are localized
|
||||
@@ -120,11 +143,13 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
|
||||
|
||||
## Recommended Flow
|
||||
|
||||
1. Find the page id from slug or metadata.
|
||||
2. Create or update the page title translations.
|
||||
3. Add sections using the dedicated block tool when possible.
|
||||
4. Read back the page with `get_dynamic_page_detail`.
|
||||
5. If a section has auto-populated fields, trust the read-back result over the raw update payload.
|
||||
1. Determine whether the page already exists.
|
||||
2. For a new page, use `create_page_with_section` for the page and first block.
|
||||
3. For an existing page, get its id and use `add_page_section`.
|
||||
4. Create or update the page title translations.
|
||||
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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import BannerForm from '../components/page-builder/BannerForm';
|
||||
import TextForm from '../components/page-builder/TextForm';
|
||||
import HeroArticleForm from '../components/page-builder/HeroArticleForm';
|
||||
import RichTextForm from '../components/page-builder/RichTextForm';
|
||||
import ArticleMediaForm from '../components/page-builder/ArticleMediaForm';
|
||||
import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm';
|
||||
import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm';
|
||||
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>',
|
||||
};
|
||||
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':
|
||||
defaultPayload = {
|
||||
title: 'Scent Notes',
|
||||
@@ -1074,6 +1084,7 @@ export default function PageBuilderPage() {
|
||||
<option value="TEXT">TEXT (About / Brand story text)</option>
|
||||
<option value="HERO_ARTICLE">HERO ARTICLE (Editorial hero)</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="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</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' && (
|
||||
<ScentNotesBreakdownForm
|
||||
editingSection={editingSection}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EditorialCarouselSectionPayloadSchema,
|
||||
HeroArticleSectionPayloadSchema,
|
||||
RichTextSectionPayloadSchema,
|
||||
ArticleMediaSectionPayloadSchema,
|
||||
ScentNotesBreakdownSectionPayloadSchema,
|
||||
QuoteCalloutSectionPayloadSchema,
|
||||
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 {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const page = await tx.page.create({
|
||||
@@ -273,7 +294,14 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
code: "PAGE_WITH_SECTION_CREATE_FAILED",
|
||||
stage: "transaction",
|
||||
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 { 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);
|
||||
if (!validationResult.success) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
code: "SECTION_PAYLOAD_INVALID",
|
||||
stage: "validate_section",
|
||||
error: "Validation failed",
|
||||
details: 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;
|
||||
}
|
||||
|
||||
return await prisma.pageSection.create({
|
||||
const section = await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId,
|
||||
type: type.toUpperCase(),
|
||||
@@ -518,10 +579,28 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
isActive: isActive !== false,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
pageId,
|
||||
pageSlug: page.slug,
|
||||
sectionId: section.id,
|
||||
section,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[PageSections] Error:", err);
|
||||
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":
|
||||
schema = RichTextSectionPayloadSchema;
|
||||
break;
|
||||
case "ARTICLE_MEDIA":
|
||||
schema = ArticleMediaSectionPayloadSchema;
|
||||
break;
|
||||
case "SCENT_NOTES_BREAKDOWN":
|
||||
schema = ScentNotesBreakdownSectionPayloadSchema;
|
||||
break;
|
||||
|
||||
@@ -17,6 +17,7 @@ const websiteBlockTypes = [
|
||||
"BANNER",
|
||||
"HERO_ARTICLE",
|
||||
"RICH_TEXT",
|
||||
"ARTICLE_MEDIA",
|
||||
"SCENT_NOTES_BREAKDOWN",
|
||||
"QUOTE_CALLOUT",
|
||||
"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 () => {
|
||||
let postCalls = 0;
|
||||
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.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
|
||||
"BANNER",
|
||||
"HERO_ARTICLE",
|
||||
"RICH_TEXT",
|
||||
"ARTICLE_MEDIA",
|
||||
"SCENT_NOTES_BREAKDOWN",
|
||||
"QUOTE_CALLOUT",
|
||||
"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: {
|
||||
type: "SCENT_NOTES_BREAKDOWN",
|
||||
requiredFields: [],
|
||||
@@ -524,6 +551,10 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
|
||||
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" }`
|
||||
},
|
||||
SCENT_NOTES_BREAKDOWN: {
|
||||
required: [],
|
||||
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
|
||||
@@ -783,6 +814,23 @@ const SECTION_TOOL_CONFIGS = [
|
||||
},
|
||||
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",
|
||||
@@ -1020,7 +1068,7 @@ export const SECTION_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -1052,7 +1100,7 @@ export const SECTION_TOOL_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
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: {
|
||||
type: "object",
|
||||
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}'`);
|
||||
}
|
||||
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,
|
||||
@@ -1414,8 +1476,10 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
|
||||
pageId: params.pageId,
|
||||
pageSlug: params.pageSlug ?? undefined,
|
||||
preview: params.pageSlug ? buildCmsPreview(params.pageSlug, params.lang || defaultLang) : undefined,
|
||||
renderability: buildRenderability(params.type),
|
||||
section: res.data,
|
||||
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)
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
@@ -1106,6 +1184,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
<TextSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'ARTICLE_MEDIA':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
<ArticleMediaSection payload={payload} lang={lang} />
|
||||
</ScrollReveal>
|
||||
)
|
||||
case 'SCENT_NOTES_BREAKDOWN':
|
||||
return (
|
||||
<ScrollReveal key={section.id}>
|
||||
|
||||
+22
-1
@@ -444,6 +444,26 @@ export const RichTextSectionPayloadSchema = z.object({
|
||||
});
|
||||
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({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
@@ -622,7 +642,7 @@ export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
|
||||
export const PAGE_SECTION_TYPES = [
|
||||
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
|
||||
'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',
|
||||
'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
|
||||
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
|
||||
@@ -641,6 +661,7 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
BANNER: BannerSectionPayloadSchema,
|
||||
HERO_ARTICLE: HeroArticleSectionPayloadSchema,
|
||||
RICH_TEXT: RichTextSectionPayloadSchema,
|
||||
ARTICLE_MEDIA: ArticleMediaSectionPayloadSchema,
|
||||
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
|
||||
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
|
||||
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,
|
||||
|
||||
Reference in new issue
Block a user