This commit is contained in:
Your Name committed 2026-07-24 21:45:44 -04:00
1 parent f8da2cceaf
commit 63bc3bfb1a
6 files changed
+658 -2

No files matched your search

+15
View File
@@ -163,6 +163,21 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
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.
## Translating Page Builder Blocks
Translate one language and one block at a time. This keeps the context compact and prevents a full payload replacement from overwriting existing languages.
1. Call `get_page_translation_status` with `pageId` and the target `lang`.
2. Pick one incomplete `sectionId`.
3. Call `get_page_section_translation` with that `sectionId` and target `lang`. The default fallback supplies source text for untranslated fields and lists those fields in `fallbackPaths`.
4. Translate the fields listed in `fallbackPaths`; preserve the payload's object and array shape.
5. Call `update_section_translation` with `translations` and the `updatedAt` value as `expectedUpdatedAt`.
6. Read the block again with `get_page_section_translation` and confirm `fallbackPaths` is empty.
`update_section_translation` changes only the requested language in registered localized fields. It preserves other languages, URLs, IDs, layout configuration, and other non-translatable values. A `SECTION_VERSION_CONFLICT` response means the block changed after it was read; fetch it again before retrying.
Use `get_page_section` with `lang=all` only when every stored language map is genuinely needed. A specific language returns resolved strings and uses fallback by default.
## What to Expect
- `PRODUCT_CAROUSEL` may return products even when you did not send them.
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import {
mergeSectionTranslations,
transformLocalizedPaths,
} from "./pages";
describe("Page section localization", () => {
const paths = ["title", "items[].title", "items[].note"];
const payload = {
title: { th: "เอกสารอ้างอิง", en: "References" },
items: [{
title: { th: "เทคโนโลยีไฟน์แบลงก์", en: "Fine blanking technology" },
note: { th: "ข้อมูลทางเทคนิค" },
url: "https://example.com/reference",
}],
variant: "numbered",
};
test("resolves only the requested language and reports missing nested fields", () => {
const result = transformLocalizedPaths(payload, paths, "zh", false);
expect(result.payload).toEqual({
title: null,
items: [{
title: null,
note: null,
url: "https://example.com/reference",
}],
variant: "numbered",
});
expect(result.fallbackPaths).toEqual([
"title",
"items[0].title",
"items[0].note",
]);
});
test("merges one language while preserving other languages and configuration", () => {
const result = mergeSectionTranslations(payload, {
title: "参考资料",
items: [{
title: "精密冲裁技术",
note: "技术资料",
}],
}, paths, "zh");
expect(result.payload).toEqual({
title: { th: "เอกสารอ้างอิง", en: "References", zh: "参考资料" },
items: [{
title: {
th: "เทคโนโลยีไฟน์แบลงก์",
en: "Fine blanking technology",
zh: "精密冲裁技术",
},
note: { th: "ข้อมูลทางเทคนิค", zh: "技术资料" },
url: "https://example.com/reference",
}],
variant: "numbered",
});
expect(result.translatedPaths).toEqual([
"title",
"items[0].title",
"items[0].note",
]);
});
test("rejects an array shape that cannot be merged safely", () => {
expect(() => mergeSectionTranslations(payload, {
items: [],
}, paths, "zh")).toThrow("Translation array shape mismatch at items");
});
});
+328 -1
View File
@@ -32,6 +32,8 @@ import {
HeroV2SectionPayloadSchema,
UspBarSectionPayloadSchema,
CustomSectionPayloadSchema,
PAGE_SECTION_LOCALIZED_PATHS,
type SectionType,
} from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
@@ -85,6 +87,130 @@ function summarizeValidationIssues(issues: z.ZodIssue[]) {
}));
}
function resolveLocalizedValue(value: any, lang: string, fallback: boolean) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
if (typeof value[lang] === "string" && value[lang]) return value[lang];
if (!fallback) return null;
return value.th ?? value.en ?? Object.values(value).find((item) => typeof item === "string") ?? null;
}
export function transformLocalizedPaths(payload: any, paths: readonly string[], lang: string, fallback: boolean) {
const result = structuredClone(payload);
const fallbackPaths: string[] = [];
const visit = (node: any, segments: string[], displayPath: string) => {
if (!node || segments.length === 0) return;
const rawSegment = segments[0];
if (!rawSegment) return;
const isArray = rawSegment.endsWith("[]");
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
const next = node[key];
if (next === undefined) return;
if (isArray) {
if (!Array.isArray(next)) return;
next.forEach((item, index) => {
const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`;
if (segments.length === 1) {
const resolved = resolveLocalizedValue(item, lang, fallback);
if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) {
fallbackPaths.push(itemPath);
}
next[index] = resolved;
} else {
visit(item, segments.slice(1), itemPath);
}
});
return;
}
const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`;
if (segments.length === 1) {
if (Array.isArray(next)) {
node[key] = next.map((item, index) => {
const resolved = resolveLocalizedValue(item, lang, fallback);
if (resolved === null || !(typeof item === "object" && item && typeof item[lang] === "string" && item[lang])) {
fallbackPaths.push(`${currentPath}[${index}]`);
}
return resolved;
});
} else {
const resolved = resolveLocalizedValue(next, lang, fallback);
if (resolved === null || !(typeof next === "object" && next && typeof next[lang] === "string" && next[lang])) {
fallbackPaths.push(currentPath);
}
node[key] = resolved;
}
return;
}
visit(next, segments.slice(1), currentPath);
};
paths.forEach((path) => visit(result, path.split("."), ""));
return { payload: result, fallbackPaths: [...new Set(fallbackPaths)] };
}
export function mergeSectionTranslations(payload: any, translations: any, paths: readonly string[], lang: string) {
const result = structuredClone(payload);
const translatedPaths: string[] = [];
const visit = (target: any, source: any, segments: string[], displayPath: string) => {
if (!target || !source || segments.length === 0) return;
const rawSegment = segments[0];
if (!rawSegment) return;
const isArray = rawSegment.endsWith("[]");
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
const targetValue = target[key];
const sourceValue = source[key];
if (targetValue === undefined || sourceValue === undefined) return;
if (isArray) {
if (!Array.isArray(targetValue) || !Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) {
throw new Error(`Translation array shape mismatch at ${key}`);
}
targetValue.forEach((item, index) => {
const itemPath = `${displayPath}${displayPath ? "." : ""}${key}[${index}]`;
if (segments.length === 1) {
if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${itemPath}`);
targetValue[index] = typeof item === "string"
? { th: item, [lang]: sourceValue[index] }
: { ...item, [lang]: sourceValue[index] };
translatedPaths.push(itemPath);
} else {
visit(item, sourceValue[index], segments.slice(1), itemPath);
}
});
return;
}
const currentPath = `${displayPath}${displayPath ? "." : ""}${key}`;
if (segments.length === 1) {
if (Array.isArray(targetValue)) {
if (!Array.isArray(sourceValue) || targetValue.length !== sourceValue.length) {
throw new Error(`Translation array shape mismatch at ${currentPath}`);
}
target[key] = targetValue.map((item, index) => {
if (typeof sourceValue[index] !== "string") throw new Error(`Translation must be a string at ${currentPath}[${index}]`);
translatedPaths.push(`${currentPath}[${index}]`);
return typeof item === "string" ? { th: item, [lang]: sourceValue[index] } : { ...item, [lang]: sourceValue[index] };
});
} else {
if (typeof sourceValue !== "string") throw new Error(`Translation must be a string at ${currentPath}`);
target[key] = typeof targetValue === "string"
? { th: targetValue, [lang]: sourceValue }
: { ...targetValue, [lang]: sourceValue };
translatedPaths.push(currentPath);
}
return;
}
visit(targetValue, sourceValue, segments.slice(1), currentPath);
};
paths.forEach((path) => visit(result, translations, path.split("."), ""));
return { payload: result, translatedPaths };
}
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async ({ query }) => {
const view = String((query as any)?.view || "summary").toLowerCase();
@@ -150,6 +276,8 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.get("/sections/:sectionId", async ({ params: { sectionId }, query, set }) => {
const lang = (query as any)?.lang || "th";
const view = String((query as any)?.view || (lang === "all" ? "all" : "resolved")).toLowerCase();
const fallback = String((query as any)?.fallback ?? "true").toLowerCase() !== "false";
const hydrate =
String((query as any)?.hydrate ?? "true").toLowerCase() !== "false";
try {
@@ -160,12 +288,89 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
set.status = 404;
return { error: "Section not found" };
}
return hydrate ? await hydrateSectionPayload(section, lang) : section;
const hydrated = hydrate ? await hydrateSectionPayload(section, lang) : section;
if (view === "all" || lang === "all") return hydrated;
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
const resolved = transformLocalizedPaths(hydrated.payload, paths, lang, fallback);
return {
...hydrated,
payload: resolved.payload,
locale: {
lang,
fallback,
missingPaths: fallback ? [] : resolved.fallbackPaths,
fallbackPaths: fallback ? resolved.fallbackPaths : [],
},
};
} catch (err) {
set.status = 500;
return { error: "Failed to fetch page section" };
}
})
.get("/translation-status/:pageId", async ({ params: { pageId }, query, set }) => {
const id = Number(pageId);
const lang = String((query as any)?.lang || "");
const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
if (!Number.isInteger(id) || id <= 0) {
set.status = 400;
return { success: false, error: "Invalid page ID" };
}
if (!supportedLangs.includes(lang)) {
set.status = 400;
return {
success: false,
error: "Unsupported language",
supportedLanguages: supportedLangs,
};
}
const page = await prisma.page.findUnique({
where: { id },
select: {
id: true,
slug: true,
sections: {
orderBy: { order: "asc" },
select: { id: true, type: true, order: true, payload: true },
},
},
});
if (!page) {
set.status = 404;
return { success: false, error: "Page not found" };
}
const sections = page.sections.map((section: {
id: number;
type: string;
order: number;
payload: unknown;
}) => {
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
const { fallbackPaths } = transformLocalizedPaths(section.payload, paths, lang, false);
return {
sectionId: section.id,
type: section.type,
order: section.order,
complete: fallbackPaths.length === 0,
missingPaths: fallbackPaths,
};
});
const missingSections = sections.filter((section: {
complete: boolean;
}) => !section.complete);
return {
success: true,
pageId: page.id,
pageSlug: page.slug,
lang,
complete: missingSections.length === 0,
translatedSections: sections.length - missingSections.length,
totalSections: sections.length,
missingSections,
};
})
.post(
"/newsletter",
async ({ body, set }) => {
@@ -195,6 +400,128 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
},
)
.use(adminOnly)
.patch(
"/sections/:sectionId/translations/:lang",
async ({ params: { sectionId, lang }, body, set }) => {
const id = Number(sectionId);
const supportedLangs = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
const input = body as {
translations?: unknown;
expectedUpdatedAt?: string;
};
if (!Number.isInteger(id) || id <= 0) {
set.status = 400;
return { success: false, error: "Invalid section ID", partialWrite: false };
}
if (!supportedLangs.includes(lang)) {
set.status = 400;
return {
success: false,
error: "Unsupported language",
supportedLanguages: supportedLangs,
partialWrite: false,
};
}
if (!input.translations || typeof input.translations !== "object") {
set.status = 400;
return {
success: false,
error: "translations must be a JSON object",
partialWrite: false,
};
}
const section = await prisma.pageSection.findUnique({ where: { id } });
if (!section) {
set.status = 404;
return { success: false, error: "Section not found", partialWrite: false };
}
if (
input.expectedUpdatedAt &&
section.updatedAt.toISOString() !== input.expectedUpdatedAt
) {
set.status = 409;
return {
success: false,
code: "SECTION_VERSION_CONFLICT",
error: "Section changed after it was read",
sectionId: id,
expectedUpdatedAt: input.expectedUpdatedAt,
actualUpdatedAt: section.updatedAt.toISOString(),
partialWrite: false,
};
}
const paths = PAGE_SECTION_LOCALIZED_PATHS[section.type as SectionType] ?? [];
if (paths.length === 0) {
set.status = 400;
return {
success: false,
code: "SECTION_TRANSLATION_UNSUPPORTED",
error: `Section type ${section.type} has no registered localized fields`,
partialWrite: false,
};
}
let merged;
try {
merged = mergeSectionTranslations(
section.payload,
input.translations,
paths,
lang,
);
} catch (error) {
set.status = 400;
return {
success: false,
code: "SECTION_TRANSLATION_SHAPE_MISMATCH",
error: error instanceof Error ? error.message : "Invalid translation shape",
partialWrite: false,
};
}
if (merged.translatedPaths.length === 0) {
set.status = 400;
return {
success: false,
code: "NO_TRANSLATABLE_FIELDS",
error: "No registered localized fields were provided",
allowedPaths: paths,
partialWrite: false,
};
}
const validationResult = validateSectionPayload(section.type, merged.payload);
if (!validationResult.success) {
set.status = 400;
return {
success: false,
code: "SECTION_PAYLOAD_INVALID",
error: "Merged section payload failed validation",
validationIssues: summarizeValidationIssues(validationResult.issues || []),
partialWrite: false,
};
}
const updated = await prisma.pageSection.update({
where: { id },
data: { payload: validationResult.data },
select: { id: true, type: true, updatedAt: true },
});
return {
success: true,
sectionId: updated.id,
sectionType: updated.type,
lang,
translatedPaths: merged.translatedPaths,
preservedOtherLanguages: true,
updatedAt: updated.updatedAt.toISOString(),
};
},
{ body: t.Any() },
)
.post(
"/with-section",
async ({ body, set }) => {
+126
View File
@@ -270,4 +270,130 @@ describe("Page Builder block contracts", () => {
],
});
});
test("get_page_section_translation requests one language with source fallback", async () => {
const calls: string[] = [];
const api = {
get: async (url: string) => {
calls.push(url);
return {
data: {
id: 41,
type: "ARTICLE_REFERENCES",
payload: {
title: "参考资料",
items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }],
},
locale: {
lang: "zh",
fallback: true,
missingPaths: [],
fallbackPaths: ["items[0].note"],
},
updatedAt: "2026-07-24T10:00:00.000Z",
},
};
},
} as any;
const result = await handleSectionTool(api, "get_page_section_translation", {
sectionId: 41,
lang: "zh",
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toEqual([
"/pages/sections/41?lang=zh&view=resolved&fallback=true",
]);
expect(response).toEqual({
sectionId: 41,
sectionType: "ARTICLE_REFERENCES",
lang: "zh",
payload: {
title: "参考资料",
items: [{ title: "精密冲裁技术", note: null, url: "https://example.com" }],
},
missingPaths: [],
fallbackPaths: ["items[0].note"],
updatedAt: "2026-07-24T10:00:00.000Z",
});
});
test("update_section_translation sends only translation fields and version", async () => {
const calls: Array<{ url: string; payload: unknown }> = [];
const api = {
patch: async (url: string, payload: unknown) => {
calls.push({ url, payload });
return {
data: {
success: true,
sectionId: 41,
sectionType: "ARTICLE_REFERENCES",
lang: "zh",
translatedPaths: ["title", "items[0].title"],
preservedOtherLanguages: true,
updatedAt: "2026-07-24T10:01:00.000Z",
},
};
},
} as any;
const translations = {
title: "参考资料",
items: [{ title: "精密冲裁技术" }],
};
const result = await handleSectionTool(api, "update_section_translation", {
sectionId: 41,
lang: "zh",
translations,
expectedUpdatedAt: "2026-07-24T10:00:00.000Z",
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toEqual([{
url: "/pages/sections/41/translations/zh",
payload: {
translations,
expectedUpdatedAt: "2026-07-24T10:00:00.000Z",
},
}]);
expect(response.preservedOtherLanguages).toBe(true);
expect(response.translatedPaths).toEqual(["title", "items[0].title"]);
});
test("get_page_translation_status requests a compact language audit", async () => {
const calls: string[] = [];
const api = {
get: async (url: string) => {
calls.push(url);
return {
data: {
success: true,
pageId: 15,
lang: "zh",
complete: false,
translatedSections: 8,
totalSections: 9,
missingSections: [{
sectionId: 41,
type: "ARTICLE_REFERENCES",
order: 9,
complete: false,
missingPaths: ["items[0].note"],
}],
},
};
},
} as any;
const result = await handleSectionTool(api, "get_page_translation_status", {
pageId: 15,
lang: "zh",
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toEqual(["/pages/translation-status/15?lang=zh"]);
expect(response.totalSections).toBe(9);
expect(response.missingSections[0].sectionId).toBe(41);
});
});
+80 -1
View File
@@ -1264,7 +1264,7 @@ export const SECTION_TOOL_DEFINITIONS = [
},
{
name: "get_page_section",
description: "Get a specific page section by ID, returning its hydrated payload resolving linked content like categories or products. Optionally pass lang to control translation.",
description: "Get a specific page section by ID. A specific lang returns localized fields as compact strings with fallback enabled; lang=all returns every stored language map.",
inputSchema: {
type: "object",
properties: {
@@ -1274,6 +1274,45 @@ export const SECTION_TOOL_DEFINITIONS = [
required: ["sectionId"]
}
},
{
name: "get_page_section_translation",
description: "Read one Page Builder block in exactly one language for translation work. Localized fields become strings; fallback defaults to true so missing translations still include source text and are listed in fallbackPaths.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to fetch" },
lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language to return" },
fallback: { type: "boolean", description: "Use another stored language when the requested language is missing. Default true so the LLM has source text to translate." }
},
required: ["sectionId", "lang"]
}
},
{
name: "update_section_translation",
description: "Patch only one language of registered localized fields in a Page Builder block. Other languages and non-translatable configuration are preserved. Pass expectedUpdatedAt from get_page_section_translation to prevent overwriting concurrent edits.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to translate" },
lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language being written" },
translations: { type: "object", description: "Only translated fields, using the same object and array shape returned by the read tool" },
expectedUpdatedAt: { type: "string", description: "Optional section updatedAt value from the read tool for optimistic concurrency" }
},
required: ["sectionId", "lang", "translations"]
}
},
{
name: "get_page_translation_status",
description: "Return compact translation completeness for every Page Builder block on one page, including section IDs and missing localized paths.",
inputSchema: {
type: "object",
properties: {
pageId: { type: "number", description: "The numeric ID of the page" },
lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language to audit" }
},
required: ["pageId", "lang"]
}
},
{
name: "add_page_section",
description: "Add one website-renderable block to an existing dynamic CMS page. Call get_dynamic_page_detail to obtain pageId, then get_page_block_schema for the selected type before composing payload. Use product detail CMS tools for product-native blocks.",
@@ -1533,6 +1572,7 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
"validate_page_section_payload",
"create_page_with_section",
"add_page_section",
"get_page_translation_status",
"reorder_page_sections",
"delete_dynamic_page",
"get_product_detail_sections",
@@ -1617,6 +1657,45 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
const res = await api.get(`/pages/sections/${params.sectionId}${langParam}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_page_section_translation": {
const fallback = params.fallback !== false;
const res = await api.get(
`/pages/sections/${params.sectionId}?lang=${encodeURIComponent(params.lang)}&view=resolved&fallback=${fallback}`,
);
return {
content: [{
type: "text",
text: JSON.stringify({
sectionId: res.data?.id ?? params.sectionId,
sectionType: res.data?.type,
lang: params.lang,
payload: res.data?.payload,
missingPaths: res.data?.locale?.missingPaths ?? [],
fallbackPaths: res.data?.locale?.fallbackPaths ?? [],
updatedAt: res.data?.updatedAt,
}, null, 2),
}],
};
}
case "update_section_translation": {
const res = await api.patch(
`/pages/sections/${params.sectionId}/translations/${encodeURIComponent(params.lang)}`,
{
translations: params.translations,
...(params.expectedUpdatedAt ? { expectedUpdatedAt: params.expectedUpdatedAt } : {}),
},
);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_page_translation_status": {
if (params.pageId === undefined) {
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
}
const res = await api.get(
`/pages/translation-status/${params.pageId}?lang=${encodeURIComponent(params.lang)}`,
);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "add_page_section": {
if (params.pageId === undefined) {
throw new Error(`Validation Error: 'pageId' is required for tool '${name}'`);
+37
View File
@@ -758,6 +758,43 @@ export const PAGE_SECTION_TYPES = [
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];
export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]> = {
HERO: ['tagline', 'title', 'description', 'buttonText'],
ANNOUNCEMENT_BAR: ['slides[]'],
NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'],
EDITORIAL_TILE: ['title', 'description', 'buttonText'],
EDITORIAL_CAROUSEL: ['title', 'slides[].category', 'slides[].title', 'slides[].description', 'slides[].buttonText'],
PRODUCTS: ['title', 'subtitle'],
CATEGORIES: ['title', 'subtitle'],
TEXT: ['title', 'content'],
BANNER: ['title', 'description', 'buttonText'],
HERO_ARTICLE: ['title', 'subtitle', 'author', 'category'],
RICH_TEXT: ['title', 'content'],
ARTICLE_HEADER: ['title', 'subtitle', 'category', 'author', 'imageAlt'],
ARTICLE_MEDIA: ['heading', 'body', 'imageAlt', 'imageCaption'],
ARTICLE_GALLERY: ['title', 'images[].alt', 'images[].caption'],
ARTICLE_FACTS: ['title', 'items[].title', 'items[].description'],
ARTICLE_VIDEO: ['title', 'caption'],
ARTICLE_REFERENCES: ['title', 'items[].title', 'items[].note'],
ARTICLE_TABLE: ['title', 'columns[]', 'rows[].label', 'rows[].cells[]', 'note'],
ARTICLE_DOWNLOAD: ['title', 'description', 'files[].title'],
ARTICLE_CTA: ['title', 'description', 'primaryText', 'secondaryText'],
SCENT_NOTES_BREAKDOWN: ['title', 'description', 'topNotes', 'heartNotes', 'middleNotes', 'baseNotes'],
QUOTE_CALLOUT: ['quote', 'author', 'role'],
PRODUCT_CAROUSEL: ['title', 'description'],
ACCORDION: ['title', 'categories[].name', 'categories[].questions[].q', 'categories[].questions[].a'],
CONTACT_INFO: ['title', 'address', 'hours'],
CONTACT_FORM: ['title', 'subtitle'],
HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'],
USP_BAR: ['items[].title', 'items[].subtitle'],
PRODUCT_MAIN: ['title', 'subtitle'],
PRODUCT_DETAILS: ['title', 'subtitle'],
PRODUCT_SPECS: ['title', 'subtitle'],
PRODUCT_REVIEWS: ['title', 'subtitle'],
RELATED_PRODUCTS: ['title', 'subtitle'],
CUSTOM: [],
};
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
HERO: HeroSectionPayloadSchema,
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,