This commit is contained in:
Your Name committed 2026-07-24 04:04:32 -04:00
1 parent 0341b30b6d
commit bd8f5882e0
9 files changed
+1410 -25

No files matched your search

+107
View File
@@ -186,6 +186,113 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}, },
) )
.use(adminOnly) .use(adminOnly)
.post(
"/with-section",
async ({ body, set }) => {
const { page: pageInput, section: sectionInput } = body as {
page: {
title: string;
slug: string;
isPublished?: boolean;
};
section: {
type: string;
payload: unknown;
order?: number;
isActive?: boolean;
};
};
if (RESERVED_SLUGS.includes(pageInput.slug.toLowerCase())) {
set.status = 400;
return {
success: false,
error: "Validation failed",
validationIssues: [
{
path: "page.slug",
message: "Reserved slug cannot be used",
code: "custom",
},
],
};
}
const validationResult = validateSectionPayload(
sectionInput.type,
sectionInput.payload,
);
if (!validationResult.success) {
set.status = 400;
return {
success: false,
error: "Validation failed",
validationIssues: summarizeValidationIssues(
validationResult.issues || [],
).map((issue) => ({
...issue,
path:
issue.path === "(root)"
? "section.payload"
: `section.payload.${issue.path}`,
})),
};
}
try {
const result = await prisma.$transaction(async (tx) => {
const page = await tx.page.create({
data: {
title: pageInput.title,
slug: pageInput.slug,
isPublished: pageInput.isPublished ?? false,
},
});
const section = await tx.pageSection.create({
data: {
pageId: page.id,
type: sectionInput.type.toUpperCase(),
payload: validationResult.data,
order: sectionInput.order ?? 1,
isActive: sectionInput.isActive ?? true,
},
});
return { page, section };
});
return {
success: true,
pageId: result.page.id,
sectionId: result.section.id,
page: result.page,
section: result.section,
};
} catch (err) {
console.error("[Pages] Atomic page creation error:", err);
set.status = 500;
return {
success: false,
error: "Failed to create page with section",
};
}
},
{
body: t.Object({
page: t.Object({
title: t.String(),
slug: t.String(),
isPublished: t.Optional(t.Boolean()),
}),
section: t.Object({
type: t.String(),
payload: t.Any(),
order: t.Optional(t.Number()),
isActive: t.Optional(t.Boolean()),
}),
}),
},
)
.post( .post(
"/", "/",
async ({ body, set }) => { async ({ body, set }) => {
+146 -5
View File
@@ -7,7 +7,7 @@ import {
import axios from "axios"; import axios from "axios";
import FormData from "form-data"; import FormData from "form-data";
import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit"; import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit";
import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit"; import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api"; const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api"; const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
@@ -22,6 +22,34 @@ export const api = axios.create({
baseURL: BACKEND_URL, baseURL: BACKEND_URL,
}); });
function currentTargetEnvironment() {
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
return "custom";
}
function normalizeToolError(err: any) {
const data = err.response?.data;
if (!data) {
return err.message;
}
const validationIssues = data.validationIssues ?? data.issues;
if (Array.isArray(validationIssues) && validationIssues.length > 0) {
const missingOrInvalid = validationIssues
.map((issue: any) => `${issue.path ?? "(root)"}: ${issue.message ?? issue.code ?? "Invalid value"}`)
.join("; ");
return JSON.stringify({
error: data.error ?? data.message ?? "Validation failed",
summary: missingOrInvalid,
validationIssues,
raw: data,
});
}
return JSON.stringify(data);
}
// MCP server state is process-local because this server runs over stdio. // MCP server state is process-local because this server runs over stdio.
api.interceptors.request.use(async (config) => { api.interceptors.request.use(async (config) => {
@@ -81,6 +109,22 @@ function compactCategory(category: any) {
}; };
} }
function compactProduct(product: any) {
if (!product) return null;
const images = Array.isArray(product.images) ? product.images : [];
const firstImage = images[0];
return {
id: product.id,
name: product.name,
slug: product.slug ?? undefined,
sku: product.sku ?? undefined,
price: product.price ?? product.variants?.[0]?.price ?? undefined,
imageUrl: firstImage?.url ?? firstImage?.imageUrl ?? product.imageUrl ?? null,
category: product.category ? compactCategory(product.category) : undefined,
brand: product.brand ? { id: product.brand.id, name: product.brand.name } : undefined,
};
}
const server = new Server( const server = new Server(
{ {
name: "homaroma-mcp", name: "homaroma-mcp",
@@ -96,6 +140,11 @@ const server = new Server(
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
return { return {
tools: [ tools: [
{
name: "get_target_environment",
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
inputSchema: { type: "object", properties: {} },
},
{ {
name: "set_target_environment", name: "set_target_environment",
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.", description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
@@ -136,6 +185,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
} }
} }
}, },
{
name: "list_categories_metadata",
description: "List compact category IDs/names for Page Builder blocks. Use this before CATEGORIES.categoryIds or PRODUCTS.categoryId.",
inputSchema: {
type: "object",
properties: {
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }
}
}
},
{ {
name: "get_category_products", name: "get_category_products",
description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.", description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.",
@@ -210,6 +269,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
} }
} }
}, },
{
name: "search_products_compact",
description: "Search products with compact output for Page Builder productIds. Prefer this over search_products before PRODUCTS.type=custom.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results. Default 10." },
lang: { type: "string", description: "Language code (e.g. th, en, all)" },
brandId: { type: "number", description: "Filter by brand ID" },
categoryId: { type: "number", description: "Filter by category ID" }
}
}
},
{ {
name: "get_product_detail", name: "get_product_detail",
description: "Get full product details including variants, images, and category", description: "Get full product details including variants, images, and category",
@@ -853,7 +926,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object", type: "object",
properties: { properties: {
slug: { type: "string", description: "Slug of the page" }, slug: { type: "string", description: "Slug of the page" },
includeInactive: { type: "boolean", description: "Include inactive sections" } includeInactive: { type: "boolean", description: "Include inactive sections" },
lang: { type: "string", description: "Preview/content language code. Default th." }
}, },
required: ["slug"] required: ["slug"]
} }
@@ -867,6 +941,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
title: { type: "string", description: "Title of the page" }, title: { type: "string", description: "Title of the page" },
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." }, slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." }, isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." },
lang: { type: "string", description: "Preview language code. Default th." },
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." } id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
}, },
required: ["title", "slug"] required: ["title", "slug"]
@@ -934,6 +1009,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
} }
switch (request.params.name) { switch (request.params.name) {
case "get_target_environment": {
return {
content: [
{
type: "text",
text: JSON.stringify({
env: currentTargetEnvironment(),
baseUrl: BACKEND_URL,
localBaseUrl: LOCAL_BACKEND_URL,
productionBaseUrl: PRODUCTION_BACKEND_URL,
authenticated: Boolean(authToken),
}, null, 2)
}
]
};
}
case "set_target_environment": { case "set_target_environment": {
const env = params.env; const env = params.env;
const customUrl = params.customUrl; const customUrl = params.customUrl;
@@ -973,6 +1064,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.get(`/categories${lang}`); const res = await api.get(`/categories${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "list_categories_metadata": {
const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : "";
const res = await api.get(`/categories${lang}`);
const categories = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
const compact = Array.isArray(categories) ? categories.map(compactCategory) : [];
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: compact.length,
categories: compact,
}, null, 2)
}]
};
}
case "get_category_products": { case "get_category_products": {
const { id } = params as any; const { id } = params as any;
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -1132,6 +1239,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.get(`/products?${search}${limit}${lang}${brandId}`); const res = await api.get(`/products?${search}${limit}${lang}${brandId}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "search_products_compact": {
const query = new URLSearchParams();
if (params.search) query.set("search", String(params.search));
query.set("limit", String(params.limit ?? 10));
if (params.lang) query.set("lang", String(params.lang));
if (params.brandId) query.set("brandId", String(params.brandId));
if (params.categoryId) query.set("categoryId", String(params.categoryId));
const res = await api.get(`/products?${query.toString()}`);
const products = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
const compact = Array.isArray(products) ? products.map(compactProduct) : [];
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: compact.length,
products: compact,
meta: res.data?.meta ?? undefined,
}, null, 2)
}]
};
}
case "get_product_detail": { case "get_product_detail": {
const lang = params.lang ? `?lang=${params.lang}` : ""; const lang = params.lang ? `?lang=${params.lang}` : "";
const res = await api.get(`/products/${params.id}${lang}`); const res = await api.get(`/products/${params.id}${lang}`);
@@ -1491,9 +1620,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
} }
case "get_dynamic_page_detail": { case "get_dynamic_page_detail": {
const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : ""); const query = new URLSearchParams();
if (params.includeInactive) query.set("includeInactive", "true");
if (params.lang) query.set("lang", String(params.lang));
const url = `/pages/${params.slug}` + (query.toString() ? `?${query.toString()}` : "");
const res = await api.get(url); const res = await api.get(url);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return {
content: [{
type: "text",
text: JSON.stringify({
...res.data,
preview: buildCmsPreview(params.slug, params.lang || "th"),
}, null, 2)
}]
};
} }
case "create_dynamic_page": { case "create_dynamic_page": {
const payload: any = { const payload: any = {
@@ -1515,6 +1655,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false, isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false,
publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"), publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"),
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."), diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
preview: buildCmsPreview(params.slug, params.lang || "th"),
page: pageData, page: pageData,
}; };
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
@@ -1567,7 +1708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error(`Unknown tool: ${request.params.name}`); throw new Error(`Unknown tool: ${request.params.name}`);
} }
} catch (err: any) { } catch (err: any) {
const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message; const errorMsg = normalizeToolError(err);
return { return {
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }], content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
isError: true, isError: true,
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test";
import {
getPageBlockSchema,
handleSectionTool,
validatePayloadAgainstMetadata,
} from "./section-toolkit";
const websiteBlockTypes = [
"HERO",
"ANNOUNCEMENT_BAR",
"NEWSLETTER",
"EDITORIAL_TILE",
"EDITORIAL_CAROUSEL",
"PRODUCTS",
"CATEGORIES",
"TEXT",
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
"ACCORDION",
"CONTACT_INFO",
"CONTACT_FORM",
"HERO_V2",
"USP_BAR",
"CUSTOM",
] as const;
describe("Page Builder block contracts", () => {
for (const type of websiteBlockTypes) {
test(`${type} exposes a valid recommended payload`, () => {
const schema = getPageBlockSchema(type);
expect(schema.renderability.websitePageRenderable).toBe(true);
expect(schema.recommendedPayload).not.toBeNull();
expect(validatePayloadAgainstMetadata(type, schema.recommendedPayload).valid).toBe(true);
});
}
test("HERO_V2 rejects the legacy empty payload before backend writes", () => {
const result = validatePayloadAgainstMetadata("HERO_V2", {});
expect(result.valid).toBe(false);
expect(result.missingFields).toEqual(["title", "imageUrl"]);
});
test("USP_BAR rejects an empty payload because the website renders nothing", () => {
const result = validatePayloadAgainstMetadata("USP_BAR", {});
expect(result.valid).toBe(false);
expect(result.missingFields).toContain("items");
});
test("CUSTOM rejects component IDs missing from the website registry", () => {
const result = validatePayloadAgainstMetadata("CUSTOM", {
componentId: "UnknownComponent",
props: {},
});
expect(result.valid).toBe(false);
expect(result.validationIssues).toContainEqual({
path: "componentId",
message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.",
code: "custom",
});
});
test("create_page_with_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, "create_page_with_section", {
title: "Invalid Hero",
slug: "invalid-hero",
type: "HERO_V2",
payload: {},
});
expect(result && "isError" in result ? result.isError : false).toBe(true);
expect(postCalls).toBe(0);
});
test("create_page_with_section uses one atomic backend call 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: 50,
sectionId: 120,
page: { id: 50, slug: "hero-v2" },
section: { id: 120, type: "HERO_V2" },
},
};
},
} as any;
const payload = getPageBlockSchema("HERO_V2").recommendedPayload;
const result = await handleSectionTool(api, "create_page_with_section", {
title: "Hero V2",
slug: "hero-v2",
type: "HERO_V2",
payload,
isPublished: false,
});
const response = JSON.parse(result?.content[0]?.text ?? "{}");
expect(calls).toHaveLength(1);
expect(calls[0]?.url).toBe("/pages/with-section");
expect(response).toEqual({
success: true,
tool: "create_page_with_section",
pageId: 50,
pageSlug: "hero-v2",
sectionId: 120,
type: "HERO_V2",
preview: {
lang: "th",
previewPath: "/th/hero-v2",
previewUrl: "/th/hero-v2",
},
warnings: [
"contentAlignment must be left, center, or right. Use an image URL allowed by apps/website.",
],
});
});
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -23,7 +23,7 @@ async function test() {
const waitResponse = async (ms: number) => { const waitResponse = async (ms: number) => {
await new Promise(resolve => setTimeout(resolve, ms)); await new Promise(resolve => setTimeout(resolve, ms));
const lines = responseData.trim().split("\n"); const lines = responseData.trim().split("\n");
const lastLine = lines[lines.length - 1]; const lastLine = lines[lines.length - 1] ?? "";
try { try {
return JSON.parse(lastLine); return JSON.parse(lastLine);
} catch (e) { } catch (e) {
+1 -1
View File
@@ -160,7 +160,7 @@ export default async function RootLayout({
const themeStyle = buildThemeStyle(settings) const themeStyle = buildThemeStyle(settings)
return ( return (
<div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}> <div className="site-theme-root min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
<ClientProviders> <ClientProviders>
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} /> <Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
<main className="global-main"> <main className="global-main">
+16 -10
View File
@@ -60,43 +60,49 @@
@layer base { @layer base {
:root, :root,
html { html {
--font-body-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; --font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; --font-display-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-serif-locale: ui-serif; --font-serif-locale: ui-serif;
} }
html[lang="en"] { [lang="en"] {
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
--font-serif-locale: ui-serif; --font-serif-locale: ui-serif;
} }
html[lang="ja"] { [lang="ja"] {
--font-body-locale: "Hiragino Sans", "Yu Gothic", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "Hiragino Sans", "Yu Gothic", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif JP", ui-serif; --font-serif-locale: "Noto Serif JP", ui-serif;
} }
html[lang="zh"] { [lang="zh"] {
--font-body-locale: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif SC", ui-serif; --font-serif-locale: "Noto Serif SC", ui-serif;
} }
html[lang="ko"] { [lang="ko"] {
--font-body-locale: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif KR", ui-serif; --font-serif-locale: "Noto Serif KR", ui-serif;
} }
html[lang="vi"] { [lang="vi"] {
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
--font-serif-locale: ui-serif; --font-serif-locale: ui-serif;
} }
html { html {
font-family: var(--font-body-locale); font-family: var(--font-body-active, var(--font-body-locale));
}
.site-theme-root {
--font-sans: var(--font-body-active, var(--font-body-locale));
--font-serif: var(--font-display-active, var(--font-display-locale));
font-family: var(--font-sans);
} }
body { body {
@@ -108,7 +114,7 @@
h1, h2, h3, h4 { h1, h2, h3, h4 {
@apply text-text-main font-semibold tracking-tight; @apply text-text-main font-semibold tracking-tight;
font-family: var(--font-display-locale); font-family: var(--font-display-active, var(--font-display-locale));
} }
} }
@@ -266,7 +272,7 @@
.ui-hero-title { .ui-hero-title {
color: var(--color-text-main); color: var(--color-text-main);
font-family: var(--font-display-locale); font-family: var(--font-display-active, var(--font-display-locale));
font-size: 3rem; font-size: 3rem;
font-weight: 300; font-weight: 300;
line-height: 1.25; line-height: 1.25;
+1 -1
View File
@@ -26,7 +26,7 @@ const playfairDisplay = Playfair_Display({
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}> <html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased"> <body className="min-h-screen bg-surface-page text-text-main antialiased">
{children} {children}
</body> </body>
</html> </html>
+28
View File
@@ -629,6 +629,34 @@ export const PAGE_SECTION_TYPES = [
] as const; ] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number]; export type SectionType = typeof PAGE_SECTION_TYPES[number];
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
HERO: HeroSectionPayloadSchema,
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
NEWSLETTER: NewsletterSectionPayloadSchema,
EDITORIAL_TILE: EditorialTileSectionPayloadSchema,
EDITORIAL_CAROUSEL: EditorialCarouselSectionPayloadSchema,
PRODUCTS: ProductsSectionPayloadSchema,
CATEGORIES: CategoriesSectionPayloadSchema,
TEXT: TextSectionPayloadSchema,
BANNER: BannerSectionPayloadSchema,
HERO_ARTICLE: HeroArticleSectionPayloadSchema,
RICH_TEXT: RichTextSectionPayloadSchema,
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,
ACCORDION: AccordionSectionPayloadSchema,
CONTACT_INFO: ContactInfoSectionPayloadSchema,
CONTACT_FORM: ContactFormSectionPayloadSchema,
HERO_V2: HeroV2SectionPayloadSchema,
USP_BAR: UspBarSectionPayloadSchema,
PRODUCT_MAIN: ProductLayoutSectionPayloadSchema,
PRODUCT_DETAILS: ProductLayoutSectionPayloadSchema,
PRODUCT_SPECS: ProductLayoutSectionPayloadSchema,
PRODUCT_REVIEWS: ProductLayoutSectionPayloadSchema,
RELATED_PRODUCTS: ProductLayoutSectionPayloadSchema,
CUSTOM: CustomSectionPayloadSchema,
};
export const PRODUCT_DETAIL_SECTION_TYPES = [ export const PRODUCT_DETAIL_SECTION_TYPES = [
'PRODUCT_MAIN', 'PRODUCT_MAIN',
'HERO', 'HERO',