x
This commit is contained in:
1 parent
0341b30b6d
commit
bd8f5882e0
9 files changed
+1410
-25
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in new issue
Block a user