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
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>
);
}
+22
View File
@@ -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}
+84 -2
View File
@@ -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;
+89
View File
@@ -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.",
],
});
});
});
+68 -4
View File
@@ -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
View File
@@ -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,