x
This commit is contained in:
1 parent
c84f7bb3ae
commit
ce6fa69b09
8 files changed
+1307
-613
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
+15
-611
@@ -1,4 +1,3 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import {
|
import {
|
||||||
@@ -7,236 +6,8 @@ import {
|
|||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import FormData from "form-data";
|
import FormData from "form-data";
|
||||||
import { PAGE_SECTION_TYPES } from "@homaroma/types";
|
import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit";
|
||||||
|
import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit";
|
||||||
function getChildrenArray(node: any, isRoot?: boolean): any[] {
|
|
||||||
if (Array.isArray(node.columns)) return node.columns;
|
|
||||||
if (Array.isArray(node.links)) return node.links;
|
|
||||||
if (Array.isArray(node.items)) return node.items;
|
|
||||||
if (Array.isArray(node.children)) return node.children;
|
|
||||||
if (isRoot) {
|
|
||||||
node.columns = [];
|
|
||||||
return node.columns;
|
|
||||||
}
|
|
||||||
node.links = [];
|
|
||||||
return node.links;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any, parent: any, list: any[], index: number } | null {
|
|
||||||
for (let i = 0; i < tree.length; i++) {
|
|
||||||
const item = tree[i];
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
if (item.nodeId === targetId) {
|
|
||||||
return { node: item, parent, list: tree, index: i };
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(item)) {
|
|
||||||
if (Array.isArray(item[key])) {
|
|
||||||
const res = findNodeAndParent(item[key], targetId, item, item[key]);
|
|
||||||
if (res) return res;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localizedStringSchema = {
|
|
||||||
oneOf: [
|
|
||||||
{ type: "string" },
|
|
||||||
{
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: { type: "string" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const localizedStringListSchema = {
|
|
||||||
type: "array",
|
|
||||||
items: localizedStringSchema
|
|
||||||
};
|
|
||||||
|
|
||||||
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
|
||||||
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
|
||||||
|
|
||||||
const SECTION_TOOL_CONFIGS = [
|
|
||||||
{
|
|
||||||
name: "update_hero_article",
|
|
||||||
sectionType: "HERO_ARTICLE",
|
|
||||||
description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
|
|
||||||
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
|
|
||||||
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
|
||||||
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
|
|
||||||
publishDate: { type: "string", description: "Plain string publish date or display date" },
|
|
||||||
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
|
|
||||||
imageUrl: { type: "string", description: "Plain string main image URL" },
|
|
||||||
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
|
|
||||||
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_rich_text",
|
|
||||||
sectionType: "RICH_TEXT",
|
|
||||||
description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
|
|
||||||
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
|
|
||||||
},
|
|
||||||
required: ["sectionId", "content"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_scent_notes_breakdown",
|
|
||||||
sectionType: "SCENT_NOTES_BREAKDOWN",
|
|
||||||
description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
|
||||||
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
|
||||||
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
|
|
||||||
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
|
||||||
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
|
||||||
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_quote_callout",
|
|
||||||
sectionType: "QUOTE_CALLOUT",
|
|
||||||
description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
|
|
||||||
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
|
||||||
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_product_carousel",
|
|
||||||
sectionType: "PRODUCT_CAROUSEL",
|
|
||||||
description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
|
||||||
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
|
||||||
collectionId: { type: "string", description: "Optional plain string collection identifier" },
|
|
||||||
limit: { type: "number", description: "Maximum number of products to show" },
|
|
||||||
showPrice: { type: "boolean", description: "Show product price" },
|
|
||||||
showAddToCart: { type: "boolean", description: "Show add to cart button" },
|
|
||||||
products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_accordion",
|
|
||||||
sectionType: "ACCORDION",
|
|
||||||
description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
|
||||||
categories: {
|
|
||||||
type: "array",
|
|
||||||
description: "Accordion categories",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
|
|
||||||
icon: { type: "string", description: "Optional icon or emoji" },
|
|
||||||
questions: {
|
|
||||||
type: "array",
|
|
||||||
description: "Questions under the category",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
|
|
||||||
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
|
|
||||||
},
|
|
||||||
required: ["q", "a"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ["name", "questions"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ["sectionId", "categories"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_contact_info",
|
|
||||||
sectionType: "CONTACT_INFO",
|
|
||||||
description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
|
||||||
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
|
|
||||||
email: { type: "string", description: "Plain string email address" },
|
|
||||||
line: { type: "string", description: "Plain string Line ID or contact" },
|
|
||||||
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
|
|
||||||
},
|
|
||||||
required: ["sectionId", "address", "email", "line", "hours"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_contact_form",
|
|
||||||
sectionType: "CONTACT_FORM",
|
|
||||||
description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`,
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
|
|
||||||
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const SECTION_TOOL_CONFIG_BY_NAME = new Map(
|
|
||||||
SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const)
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildSectionPayload(params: any, fields: readonly string[]) {
|
|
||||||
const payload: Record<string, any> = {};
|
|
||||||
for (const field of fields) {
|
|
||||||
if (params[field] !== undefined) {
|
|
||||||
payload[field] = params[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
|
|
||||||
const notes: string[] = [];
|
|
||||||
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
|
|
||||||
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
|
|
||||||
}
|
|
||||||
if (sectionType === "PRODUCT_CAROUSEL") {
|
|
||||||
notes.push("If products was omitted, the backend may populate products on read-back");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: JSON.stringify(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
tool: toolName,
|
|
||||||
sectionType,
|
|
||||||
sectionId: updatedSection?.id,
|
|
||||||
updatedFields: Object.keys(payload),
|
|
||||||
appliedPayload: payload,
|
|
||||||
notes,
|
|
||||||
section: updatedSection
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -302,53 +73,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["env"]
|
required: ["env"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
...MENU_TOOL_DEFINITIONS,
|
||||||
name: "get_menu",
|
...SECTION_TOOL_DEFINITIONS,
|
||||||
description: "Fetch a specific menu and its items (including translations)",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }
|
|
||||||
},
|
|
||||||
required: ["name"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upsert_menu",
|
|
||||||
description: "Update or create a complete menu by replacing its items. Uses proper diff & sync for translations.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" },
|
|
||||||
items: {
|
|
||||||
type: "array",
|
|
||||||
description: "Array of menu items",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: { type: "number", description: "ID of existing item (optional)" },
|
|
||||||
label: { type: "string", description: "Label of the menu item" },
|
|
||||||
url: { type: "string", description: "URL of the menu item" },
|
|
||||||
order: { type: "number", description: "Order sequence" },
|
|
||||||
translations: {
|
|
||||||
type: "array",
|
|
||||||
description: "Optional array of translations",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
langCode: { type: "string" },
|
|
||||||
label: { type: "string" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ["label", "url", "order"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ["name", "items"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "ping_backend",
|
name: "ping_backend",
|
||||||
description: "Ping the backend to verify connection and authentication",
|
description: "Ping the backend to verify connection and authentication",
|
||||||
@@ -638,56 +364,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["title", "slug"]
|
required: ["title", "slug"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
...SECTION_TOOL_DEFINITIONS,
|
||||||
name: "add_page_section",
|
|
||||||
description: "Add a block section to a dynamic page layout",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
pageId: { type: "number", description: "The numeric ID of the page" },
|
|
||||||
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
|
|
||||||
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
|
||||||
payload: { type: "object", description: "The JSON payload matching the section type rules" },
|
|
||||||
isActive: { type: "boolean", description: "Initial active state" }
|
|
||||||
},
|
|
||||||
required: ["pageId", "type", "payload"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_page_section",
|
|
||||||
description: "Update an existing block section payload, order or active state",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
|
||||||
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional new block type" },
|
|
||||||
order: { type: "number", description: "Optional new display order sequence" },
|
|
||||||
payload: { type: "object", description: "Optional new JSON payload matching the section type rules" },
|
|
||||||
isActive: { type: "boolean", description: "Optional new active state" }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...SECTION_TOOL_CONFIGS.map((config) => ({
|
|
||||||
name: config.name,
|
|
||||||
description: config.description,
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: config.properties,
|
|
||||||
required: config.required
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
name: "delete_page_section",
|
|
||||||
description: "Delete a block section from a dynamic page layout",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
|
|
||||||
},
|
|
||||||
required: ["sectionId"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "update_footer_content",
|
name: "update_footer_content",
|
||||||
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
|
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
|
||||||
@@ -731,67 +408,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "audit_missing_pages",
|
|
||||||
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
|
|
||||||
inputSchema: { type: "object", properties: {} }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "get_mega_menu_tree",
|
|
||||||
description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the tree.",
|
|
||||||
inputSchema: { type: "object", properties: {} }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "add_menu_node",
|
|
||||||
description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
|
||||||
nodeData: { type: "object", description: "Node data including label, url, translations, etc." }
|
|
||||||
},
|
|
||||||
required: ["nodeData"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "update_menu_translation",
|
|
||||||
description: "Update 6-language translations for a specific node in the Mega Menu",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
nodeId: { type: "string", description: "UUID of the node" },
|
|
||||||
translations: {
|
|
||||||
type: "object",
|
|
||||||
description: "Object containing language codes as keys (th, en, etc.) and translated strings as values."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
required: ["nodeId", "translations"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "move_menu_node",
|
|
||||||
description: "Move a node to a new parent or change its order",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
nodeId: { type: "string" },
|
|
||||||
newParentId: { type: "string", description: "UUID of new parent, or 'root'" },
|
|
||||||
orderIndex: { type: "number", description: "New index in the parent's children array" }
|
|
||||||
},
|
|
||||||
required: ["nodeId"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "remove_menu_node",
|
|
||||||
description: "Remove a node and all its children from the Mega Menu",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
nodeId: { type: "string" }
|
|
||||||
},
|
|
||||||
required: ["nodeId"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -800,6 +416,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const params = request.params.arguments as any;
|
const params = request.params.arguments as any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const menuToolResponse = await handleMenuTool(api, request.params.name, params);
|
||||||
|
if (menuToolResponse) {
|
||||||
|
return menuToolResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionToolResponse = await handleSectionTool(api, request.params.name, params);
|
||||||
|
if (sectionToolResponse) {
|
||||||
|
return sectionToolResponse;
|
||||||
|
}
|
||||||
|
|
||||||
switch (request.params.name) {
|
switch (request.params.name) {
|
||||||
case "set_target_environment": {
|
case "set_target_environment": {
|
||||||
const env = params.env;
|
const env = params.env;
|
||||||
@@ -827,10 +453,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const res = await api.get(`/menus/${params.name}`);
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "upsert_menu": {
|
|
||||||
const res = await api.put(`/menus/${params.name}`, { items: params.items });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "search_products": {
|
case "search_products": {
|
||||||
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
||||||
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
||||||
@@ -1074,57 +696,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
};
|
};
|
||||||
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "add_page_section": {
|
|
||||||
const payload: any = {
|
|
||||||
type: params.type,
|
|
||||||
payload: params.payload,
|
|
||||||
isActive: params.isActive !== false
|
|
||||||
};
|
|
||||||
if (params.order !== undefined) payload.order = params.order;
|
|
||||||
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "update_page_section": {
|
|
||||||
const payload: any = {};
|
|
||||||
if (params.type) payload.type = params.type;
|
|
||||||
if (params.order !== undefined) payload.order = params.order;
|
|
||||||
if (params.payload) payload.payload = params.payload;
|
|
||||||
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
|
||||||
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
|
||||||
return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload);
|
|
||||||
}
|
|
||||||
case "update_hero_article":
|
|
||||||
case "update_rich_text":
|
|
||||||
case "update_scent_notes_breakdown":
|
|
||||||
case "update_quote_callout":
|
|
||||||
case "update_product_carousel":
|
|
||||||
case "update_accordion":
|
|
||||||
case "update_contact_info":
|
|
||||||
case "update_contact_form": {
|
|
||||||
const config = SECTION_TOOL_CONFIG_BY_NAME.get(request.params.name);
|
|
||||||
if (!config) {
|
|
||||||
throw new Error(`Unknown tool config for: ${request.params.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
|
||||||
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
|
|
||||||
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
|
|
||||||
payload.middleNotes = payload.heartNotes;
|
|
||||||
} else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) {
|
|
||||||
payload.heartNotes = payload.middleNotes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await api.put(`/pages/sections/${params.sectionId}`, {
|
|
||||||
type: config.sectionType,
|
|
||||||
...payload
|
|
||||||
});
|
|
||||||
|
|
||||||
return buildSectionUpdateResult(request.params.name, config.sectionType, res.data, payload);
|
|
||||||
}
|
|
||||||
case "delete_page_section": {
|
|
||||||
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "update_footer_content": {
|
case "update_footer_content": {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@@ -1169,173 +740,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "audit_missing_pages": {
|
|
||||||
// 1. Fetch all menus
|
|
||||||
const menusRes = await api.get("/menus");
|
|
||||||
const menus = menusRes.data || [];
|
|
||||||
|
|
||||||
// 2. Fetch all dynamic pages
|
|
||||||
const pagesRes = await api.get("/pages");
|
|
||||||
const pages = pagesRes.data || [];
|
|
||||||
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
|
|
||||||
|
|
||||||
// 3. Known SPA Hardcoded Routes
|
|
||||||
const hardcodedRoutes = [
|
|
||||||
"/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"
|
|
||||||
];
|
|
||||||
|
|
||||||
const missingLinks: any[] = [];
|
|
||||||
|
|
||||||
// 4. Audit
|
|
||||||
menus.forEach((menu: any) => {
|
|
||||||
if (menu.items && Array.isArray(menu.items)) {
|
|
||||||
menu.items.forEach((item: any) => {
|
|
||||||
const url = item.url;
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
// Skip external links
|
|
||||||
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
|
|
||||||
|
|
||||||
// Skip SPA Hardcoded routes
|
|
||||||
if (hardcodedRoutes.includes(url)) return;
|
|
||||||
|
|
||||||
// Skip dynamic collection routes natively handled by SPA (e.g. /collections/...)
|
|
||||||
if (url.startsWith("/collections/")) return;
|
|
||||||
|
|
||||||
// Check if it exists in created dynamic pages
|
|
||||||
if (!createdSlugs.includes(url)) {
|
|
||||||
missingLinks.push({
|
|
||||||
menuName: menu.name,
|
|
||||||
label: item.label,
|
|
||||||
url: url,
|
|
||||||
suggestedSlug: url.startsWith("/") ? url.substring(1) : url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ missingPages: missingLinks }, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "get_mega_menu_tree": {
|
|
||||||
const res = await api.get(`/menus/main`);
|
|
||||||
const menu = res.data;
|
|
||||||
if (!menu.payload) menu.payload = [];
|
|
||||||
|
|
||||||
let modified = false;
|
|
||||||
function ensureNodeIds(obj: any) {
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
obj.forEach(ensureNodeIds);
|
|
||||||
} else if (obj !== null && typeof obj === 'object') {
|
|
||||||
if (!obj.nodeId) {
|
|
||||||
obj.nodeId = crypto.randomUUID();
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
Object.values(obj).forEach(ensureNodeIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureNodeIds(menu.payload);
|
|
||||||
|
|
||||||
if (modified) {
|
|
||||||
await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(menu.payload, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "add_menu_node": {
|
|
||||||
const { parentId, nodeData } = params;
|
|
||||||
const res = await api.get(`/menus/main`);
|
|
||||||
const menu = res.data;
|
|
||||||
if (!menu.payload) menu.payload = [];
|
|
||||||
|
|
||||||
nodeData.nodeId = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Map translations to label/title
|
|
||||||
if (nodeData.translations) {
|
|
||||||
const labelKey = nodeData.title !== undefined ? 'title' : 'label';
|
|
||||||
if (typeof nodeData[labelKey] === 'string') {
|
|
||||||
// fallback if it was a string
|
|
||||||
nodeData[labelKey] = { en: nodeData[labelKey] };
|
|
||||||
}
|
|
||||||
nodeData[labelKey] = { ...(nodeData[labelKey] || {}), ...nodeData.translations };
|
|
||||||
delete nodeData.translations;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parentId || parentId === 'root') {
|
|
||||||
if (nodeData.url && !nodeData.columns && !nodeData.items && !nodeData.children) {
|
|
||||||
throw new Error("Invalid schema: Cannot add a Link directly under Root. Please create a Column first.");
|
|
||||||
}
|
|
||||||
menu.payload.push(nodeData);
|
|
||||||
} else {
|
|
||||||
const found = findNodeAndParent(menu.payload, parentId);
|
|
||||||
if (!found) {
|
|
||||||
throw new Error(`Parent node ${parentId} not found`);
|
|
||||||
}
|
|
||||||
const children = getChildrenArray(found.node, found.parent === null);
|
|
||||||
children.push(nodeData);
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: nodeData.nodeId, payload: menu.payload }, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "update_menu_translation": {
|
|
||||||
const { nodeId, translations } = params;
|
|
||||||
const res = await api.get(`/menus/main`);
|
|
||||||
const menu = res.data;
|
|
||||||
if (!menu.payload) menu.payload = [];
|
|
||||||
|
|
||||||
const found = findNodeAndParent(menu.payload, nodeId);
|
|
||||||
if (!found) throw new Error(`Node ${nodeId} not found`);
|
|
||||||
|
|
||||||
const labelKey = found.node.title !== undefined ? 'title' : 'label';
|
|
||||||
found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations };
|
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, node: found.node }, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "move_menu_node": {
|
|
||||||
const { nodeId, newParentId, orderIndex } = params;
|
|
||||||
const res = await api.get(`/menus/main`);
|
|
||||||
const menu = res.data;
|
|
||||||
if (!menu.payload) menu.payload = [];
|
|
||||||
|
|
||||||
const found = findNodeAndParent(menu.payload, nodeId);
|
|
||||||
if (!found) throw new Error(`Node ${nodeId} not found`);
|
|
||||||
|
|
||||||
// Remove from current list
|
|
||||||
found.list.splice(found.index, 1);
|
|
||||||
|
|
||||||
let targetList = menu.payload;
|
|
||||||
if (newParentId && newParentId !== 'root') {
|
|
||||||
const newParent = findNodeAndParent(menu.payload, newParentId);
|
|
||||||
if (!newParent) throw new Error(`New parent ${newParentId} not found`);
|
|
||||||
targetList = getChildrenArray(newParent.node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof orderIndex === 'number' && orderIndex >= 0) {
|
|
||||||
targetList.splice(orderIndex, 0, found.node);
|
|
||||||
} else {
|
|
||||||
targetList.push(found.node);
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: menu.payload }, null, 2) }] };
|
|
||||||
}
|
|
||||||
case "remove_menu_node": {
|
|
||||||
const { nodeId } = params;
|
|
||||||
const res = await api.get(`/menus/main`);
|
|
||||||
const menu = res.data;
|
|
||||||
if (!menu.payload) menu.payload = [];
|
|
||||||
|
|
||||||
const found = findNodeAndParent(menu.payload, nodeId);
|
|
||||||
if (!found) throw new Error(`Node ${nodeId} not found`);
|
|
||||||
|
|
||||||
found.list.splice(found.index, 1);
|
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: menu.payload, items: menu.items || [] });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: menu.payload }, null, 2) }] };
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import type { AxiosInstance } from "axios";
|
||||||
|
import {
|
||||||
|
applyBulkMenuOperations,
|
||||||
|
applyMenuAddNode,
|
||||||
|
applyMenuMoveNode,
|
||||||
|
applyMenuRemoveNode,
|
||||||
|
applyMenuUpdateTranslation,
|
||||||
|
collectMenuLinksFromTree,
|
||||||
|
createMenuSnapshot,
|
||||||
|
describeMenuNode,
|
||||||
|
normalizeMenuPayloadForWrite,
|
||||||
|
restoreMenuSnapshot,
|
||||||
|
validateMenuItems,
|
||||||
|
validateMenuNodeData,
|
||||||
|
validateMenuTranslations,
|
||||||
|
validateMenuTreeSnapshot,
|
||||||
|
cloneMenuValue,
|
||||||
|
isPlainObject,
|
||||||
|
} from "./menu-tools";
|
||||||
|
|
||||||
|
export const MENU_TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
name: "get_menu",
|
||||||
|
description: "Fetch a specific menu and its items (including translations)",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upsert_menu",
|
||||||
|
description: "Update or create a complete menu by replacing its items. Optional payload/isActive support covers tree-based menus and menu state. Uses proper diff & sync for translations.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
|
payload: {
|
||||||
|
oneOf: [{ type: "object" }, { type: "array" }],
|
||||||
|
description: "Optional menu payload for tree-based menus such as main"
|
||||||
|
},
|
||||||
|
isActive: { type: "boolean", description: "Optional active state for the menu" },
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
description: "Array of menu items",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "ID of existing item (optional)" },
|
||||||
|
label: { type: "string", description: "Label of the menu item" },
|
||||||
|
url: { type: "string", description: "URL of the menu item" },
|
||||||
|
order: { type: "number", description: "Order sequence" },
|
||||||
|
translations: {
|
||||||
|
type: "array",
|
||||||
|
description: "Optional array of translations",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
langCode: { type: "string" },
|
||||||
|
label: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["label", "url", "order"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audit_missing_pages",
|
||||||
|
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
|
||||||
|
inputSchema: { type: "object", properties: {} }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_mega_menu_tree",
|
||||||
|
description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the tree.",
|
||||||
|
inputSchema: { type: "object", properties: {} }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "capture_menu_snapshot",
|
||||||
|
description: "Capture the current state of a menu so it can be restored later.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Name of the menu to snapshot (e.g. main, footer-explore)" }
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "restore_menu_snapshot",
|
||||||
|
description: "Restore a previously captured menu snapshot by snapshotId.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
snapshotId: { type: "string", description: "Snapshot identifier returned by capture_menu_snapshot" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
||||||
|
},
|
||||||
|
required: ["snapshotId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bulk_menu_operations",
|
||||||
|
description: "Apply multiple menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Name of the menu to modify (e.g. main, footer-explore)" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
|
operations: {
|
||||||
|
type: "array",
|
||||||
|
description: "Array of menu operations to apply in order",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", enum: ["add_node", "update_translation", "move_node", "remove_node"] },
|
||||||
|
parentId: { type: "string", description: "Parent node ID for add_node" },
|
||||||
|
nodeId: { type: "string", description: "Target node ID for update/move/remove" },
|
||||||
|
newParentId: { type: "string", description: "New parent node ID for move_node" },
|
||||||
|
orderIndex: { type: "number", description: "Insertion index for move_node" },
|
||||||
|
translations: { type: "object", description: "Translation map for update_translation" },
|
||||||
|
nodeData: { type: "object", description: "Node data for add_node" }
|
||||||
|
},
|
||||||
|
required: ["type"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name", "operations"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add_menu_node",
|
||||||
|
description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
parentId: { type: "string", description: "UUID of parent node, or 'root'" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
|
nodeData: { type: "object", description: "Node data including label, url, translations, etc." }
|
||||||
|
},
|
||||||
|
required: ["nodeData"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_menu_translation",
|
||||||
|
description: "Update 6-language translations for a specific node in the Mega Menu",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string", description: "UUID of the node" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
description: "Object containing language codes as keys (th, en, etc.) and translated strings as values."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["nodeId", "translations"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move_menu_node",
|
||||||
|
description: "Move a node to a new parent or change its order",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string" },
|
||||||
|
newParentId: { type: "string", description: "UUID of new parent, or 'root'" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" },
|
||||||
|
orderIndex: { type: "number", description: "New index in the parent's children array" }
|
||||||
|
},
|
||||||
|
required: ["nodeId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_menu_node",
|
||||||
|
description: "Remove a node and all its children from the Mega Menu",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
nodeId: { type: "string" },
|
||||||
|
dryRun: { type: "boolean", description: "Preview the mutation without saving it" }
|
||||||
|
},
|
||||||
|
required: ["nodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MENU_TOOL_NAMES = new Set(MENU_TOOL_DEFINITIONS.map((tool) => tool.name));
|
||||||
|
|
||||||
|
export async function handleMenuTool(api: AxiosInstance, name: string, params: any) {
|
||||||
|
if (!MENU_TOOL_NAMES.has(name)) return null;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "get_menu": {
|
||||||
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "upsert_menu": {
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const items = Array.isArray(params.items) ? params.items : [];
|
||||||
|
const body: any = { items };
|
||||||
|
const itemCheck = validateMenuItems(items);
|
||||||
|
if (itemCheck.issues.length > 0) {
|
||||||
|
throw new Error(`Invalid menu items: ${itemCheck.issues.join("; ")}`);
|
||||||
|
}
|
||||||
|
if (params.payload !== undefined) body.payload = params.payload;
|
||||||
|
if (params.isActive !== undefined) body.isActive = params.isActive;
|
||||||
|
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
||||||
|
throw new Error("Invalid menu payload: payload must be an object or array");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
dryRun: true,
|
||||||
|
operation: "upsert_menu",
|
||||||
|
menuName: params.name,
|
||||||
|
preview: {
|
||||||
|
name: params.name,
|
||||||
|
items: cloneMenuValue(items),
|
||||||
|
payload: params.payload !== undefined ? cloneMenuValue(params.payload) : undefined,
|
||||||
|
isActive: params.isActive
|
||||||
|
},
|
||||||
|
warnings: itemCheck.warnings
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.put(`/menus/${params.name}`, body);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "audit_missing_pages": {
|
||||||
|
const menusRes = await api.get("/menus");
|
||||||
|
const menus = menusRes.data || [];
|
||||||
|
const pagesRes = await api.get("/pages");
|
||||||
|
const pages = pagesRes.data || [];
|
||||||
|
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
|
||||||
|
const hardcodedRoutes = ["/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"];
|
||||||
|
|
||||||
|
const missingLinks: any[] = [];
|
||||||
|
const duplicates: any[] = [];
|
||||||
|
const treeIssues: any[] = [];
|
||||||
|
|
||||||
|
menus.forEach((menu: any) => {
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
const urls = [
|
||||||
|
...(Array.isArray(menu.items)
|
||||||
|
? menu.items.filter((item: any) => item?.url).map((item: any) => ({ url: item.url, label: item.label, source: "items" as const }))
|
||||||
|
: []),
|
||||||
|
...(Array.isArray(menu.payload) ? collectMenuLinksFromTree(menu.payload) : []),
|
||||||
|
];
|
||||||
|
const uniqueUrls = [...new Map(urls.map((entry) => [entry.url, entry])).values()];
|
||||||
|
|
||||||
|
uniqueUrls.forEach(({ url, label, source }) => {
|
||||||
|
if (seenUrls.has(url)) {
|
||||||
|
duplicates.push({ menuName: menu.name, url, label: label || url, source });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenUrls.add(url);
|
||||||
|
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
|
||||||
|
if (hardcodedRoutes.includes(url)) return;
|
||||||
|
if (url.startsWith("/collections/")) return;
|
||||||
|
if (!createdSlugs.includes(url)) {
|
||||||
|
missingLinks.push({
|
||||||
|
menuName: menu.name,
|
||||||
|
label: label || url,
|
||||||
|
url,
|
||||||
|
suggestedSlug: url.startsWith("/") ? url.substring(1) : url,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(menu.payload)) {
|
||||||
|
const treeCheck = validateMenuTreeSnapshot(menu.payload);
|
||||||
|
treeIssues.push({ menuName: menu.name, issues: treeCheck.issues, warnings: treeCheck.warnings });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ missingPages: missingLinks, duplicates, treeIssues }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "get_mega_menu_tree": {
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const normalized = normalizeMenuPayloadForWrite(menu.payload);
|
||||||
|
if (normalized.modified) {
|
||||||
|
await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] });
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "capture_menu_snapshot": {
|
||||||
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
|
const snapshot = createMenuSnapshot(res.data);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
operation: "capture_menu_snapshot",
|
||||||
|
snapshotId: snapshot.snapshotId,
|
||||||
|
name: snapshot.name,
|
||||||
|
createdAt: snapshot.createdAt,
|
||||||
|
itemCount: snapshot.items.length,
|
||||||
|
hasTreePayload: snapshot.payload.length > 0
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "restore_menu_snapshot": {
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const snapshot = restoreMenuSnapshot(params.snapshotId);
|
||||||
|
const preview = { name: snapshot.name, payload: snapshot.payload, items: snapshot.items, isActive: snapshot.isActive };
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, dryRun: true, operation: "restore_menu_snapshot", snapshotId: params.snapshotId, preview }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.put(`/menus/${snapshot.name}`, {
|
||||||
|
payload: snapshot.payload,
|
||||||
|
items: snapshot.items,
|
||||||
|
isActive: snapshot.isActive
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, snapshotId: params.snapshotId, menu: res.data }, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "bulk_menu_operations": {
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
if (!Array.isArray(params.operations) || params.operations.length === 0) {
|
||||||
|
throw new Error("operations must be a non-empty array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.get(`/menus/${params.name}`);
|
||||||
|
const menu = res.data;
|
||||||
|
const result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { payload: result.payload } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeMenuPayloadForWrite(result.payload);
|
||||||
|
const writeBody: any = { payload: normalized.payload, items: menu.items || [] };
|
||||||
|
if (menu.isActive !== undefined) {
|
||||||
|
writeBody.isActive = menu.isActive;
|
||||||
|
}
|
||||||
|
const writeRes = await api.put(`/menus/${params.name}`, writeBody);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, menu: writeRes.data }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "add_menu_node": {
|
||||||
|
const { parentId, nodeData } = params;
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const validation = validateMenuNodeData(nodeData, parentId);
|
||||||
|
if (validation.issues.length > 0) {
|
||||||
|
throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`);
|
||||||
|
}
|
||||||
|
const result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: result.payload }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, payload: result.payload }, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_menu_translation": {
|
||||||
|
const { nodeId, translations } = params;
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const validation = validateMenuTranslations(translations);
|
||||||
|
if (validation.issues.length > 0) {
|
||||||
|
throw new Error(`Invalid translations: ${validation.issues.join("; ")}`);
|
||||||
|
}
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: result.payload }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, payload: result.payload }, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "move_menu_node": {
|
||||||
|
const { nodeId, newParentId, orderIndex } = params;
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const result = applyMenuMoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId, newParentId, orderIndex);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: result.payload }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "remove_menu_node": {
|
||||||
|
const { nodeId } = params;
|
||||||
|
const dryRun = !!params.dryRun;
|
||||||
|
const res = await api.get(`/menus/main`);
|
||||||
|
const menu = res.data;
|
||||||
|
const result = applyMenuRemoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: result.payload }, null, 2)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const;
|
||||||
|
const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const;
|
||||||
|
|
||||||
|
function isPlainObject(value: any) {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStructuralMenuNode(value: any) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
return MENU_NODE_VALUE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMenuValue<T>(value: T): T {
|
||||||
|
const cloneFn = (globalThis as any).structuredClone;
|
||||||
|
if (typeof cloneFn === "function") {
|
||||||
|
return cloneFn(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChildrenArray(node: any, isRoot?: boolean): any[] {
|
||||||
|
if (Array.isArray(node.columns)) return node.columns;
|
||||||
|
if (Array.isArray(node.links)) return node.links;
|
||||||
|
if (Array.isArray(node.items)) return node.items;
|
||||||
|
if (Array.isArray(node.children)) return node.children;
|
||||||
|
if (isRoot) {
|
||||||
|
node.columns = [];
|
||||||
|
return node.columns;
|
||||||
|
}
|
||||||
|
node.links = [];
|
||||||
|
return node.links;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodeAndParent(tree: any[], targetId: string, parent: any = null, list: any[] = tree): { node: any; parent: any; list: any[]; index: number } | null {
|
||||||
|
for (let i = 0; i < tree.length; i++) {
|
||||||
|
const item = tree[i];
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
if (item.nodeId === targetId) {
|
||||||
|
return { node: item, parent, list: tree, index: i };
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(item)) {
|
||||||
|
if (Array.isArray(item[key])) {
|
||||||
|
const res = findNodeAndParent(item[key], targetId, item, item[key]);
|
||||||
|
if (res) return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMenuLinksFromTree(tree: any): Array<{ url: string; label?: any; source: "payload" }> {
|
||||||
|
const links: Array<{ url: string; label?: any; source: "payload" }> = [];
|
||||||
|
const visited = new WeakSet<object>();
|
||||||
|
|
||||||
|
const walk = (value: any) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
if (visited.has(value)) return;
|
||||||
|
visited.add(value);
|
||||||
|
|
||||||
|
if (!isStructuralMenuNode(value)) return;
|
||||||
|
|
||||||
|
if (typeof value.url === "string" && value.url.trim()) {
|
||||||
|
links.push({ url: value.url, label: value.label ?? value.title, source: "payload" });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
|
if (Array.isArray(value[key])) {
|
||||||
|
walk(value[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(tree);
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listStructuralKeys(node: any) {
|
||||||
|
return MENU_NODE_ARRAY_KEYS.filter((key) => Array.isArray(node?.[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMenuNode(node: any) {
|
||||||
|
if (!isPlainObject(node)) return "invalid node";
|
||||||
|
if (typeof node.url === "string" && node.url.trim()) return `link ${node.url}`;
|
||||||
|
const structuralKeys = listStructuralKeys(node);
|
||||||
|
if (structuralKeys.includes("columns")) return "column container";
|
||||||
|
if (structuralKeys.length > 0) return `container (${structuralKeys.join(", ")})`;
|
||||||
|
return "menu node";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMenuNodeData(nodeData: any, parentId?: string | null) {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!isPlainObject(nodeData)) {
|
||||||
|
issues.push("nodeData must be a plain object");
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUrl = typeof nodeData.url === "string" && nodeData.url.trim();
|
||||||
|
const hasLabel = nodeData.label !== undefined || nodeData.title !== undefined;
|
||||||
|
const structuralKeys = listStructuralKeys(nodeData);
|
||||||
|
|
||||||
|
for (const key of ["label", "title"] as const) {
|
||||||
|
const value = nodeData[key];
|
||||||
|
if (value !== undefined && !isPlainObject(value) && typeof value !== "string") {
|
||||||
|
issues.push(`${key} must be a string or localized object`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeData.url !== undefined && typeof nodeData.url !== "string") {
|
||||||
|
issues.push("url must be a string when provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
|
if (nodeData[key] !== undefined && !Array.isArray(nodeData[key])) {
|
||||||
|
issues.push(`${key} must be an array when provided`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId === "root" && hasUrl && structuralKeys.length === 0) {
|
||||||
|
issues.push("cannot add a leaf link directly under root; create a column container first");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUrl && !hasLabel) {
|
||||||
|
warnings.push("link node does not include a label/title yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeData.translations !== undefined && !isPlainObject(nodeData.translations) && !Array.isArray(nodeData.translations)) {
|
||||||
|
issues.push("translations must be an object or array when provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMenuTranslations(translations: any) {
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!isPlainObject(translations)) {
|
||||||
|
issues.push("translations must be a plain object");
|
||||||
|
return { issues };
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(translations)) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
issues.push(`translation for "${key}" must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMenuItems(items: any) {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
issues.push("items must be an array");
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
if (!isPlainObject(item)) {
|
||||||
|
issues.push(`items[${index}] must be a plain object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.url !== "string" || !item.url.trim()) {
|
||||||
|
issues.push(`items[${index}].url must be a non-empty string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.label !== "string" || !item.label.trim()) {
|
||||||
|
warnings.push(`items[${index}] is missing a human-readable label`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.order !== undefined && typeof item.order !== "number") {
|
||||||
|
issues.push(`items[${index}].order must be a number when provided`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.translations !== undefined) {
|
||||||
|
if (!Array.isArray(item.translations)) {
|
||||||
|
issues.push(`items[${index}].translations must be an array when provided`);
|
||||||
|
} else {
|
||||||
|
item.translations.forEach((translation: any, translationIndex: number) => {
|
||||||
|
if (!isPlainObject(translation)) {
|
||||||
|
issues.push(`items[${index}].translations[${translationIndex}] must be a plain object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof translation.langCode !== "string" || !translation.langCode.trim()) {
|
||||||
|
issues.push(`items[${index}].translations[${translationIndex}].langCode must be a non-empty string`);
|
||||||
|
}
|
||||||
|
if (typeof translation.label !== "string" || !translation.label.trim()) {
|
||||||
|
issues.push(`items[${index}].translations[${translationIndex}].label must be a non-empty string`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuTreeMutationResult = {
|
||||||
|
payload: any[];
|
||||||
|
warnings: string[];
|
||||||
|
nodeId?: string;
|
||||||
|
removedNode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuSnapshots = new Map<string, { name: string; payload: any[]; items: any[]; isActive?: boolean; createdAt: string }>();
|
||||||
|
|
||||||
|
function ensureMenuPayloadArray(value: any) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMenuTreeSnapshot(payload: any[]) {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const seenNodeIds = new Set<string>();
|
||||||
|
|
||||||
|
const walk = (value: any, path: string) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((child, index) => walk(child, `${path}[${index}]`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) return;
|
||||||
|
|
||||||
|
if (!isStructuralMenuNode(value)) {
|
||||||
|
warnings.push(`${path}: non-structural object present in menu tree`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.nodeId && typeof value.nodeId === "string") {
|
||||||
|
if (seenNodeIds.has(value.nodeId)) {
|
||||||
|
issues.push(`${path}: duplicate nodeId ${value.nodeId}`);
|
||||||
|
}
|
||||||
|
seenNodeIds.add(value.nodeId);
|
||||||
|
} else {
|
||||||
|
warnings.push(`${path}: missing nodeId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key]));
|
||||||
|
if (!hasChildren && !value.url) {
|
||||||
|
warnings.push(`${path}: container node has no children and no url`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
|
if (Array.isArray(value[key])) {
|
||||||
|
walk(value[key], `${path}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(payload, "payload");
|
||||||
|
return { issues, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult {
|
||||||
|
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
|
||||||
|
const nextNode = cloneMenuValue(nodeData);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
nextNode.nodeId = crypto.randomUUID();
|
||||||
|
|
||||||
|
if (nextNode.translations) {
|
||||||
|
const labelKey = nextNode.title !== undefined ? "title" : "label";
|
||||||
|
if (typeof nextNode[labelKey] === "string") {
|
||||||
|
nextNode[labelKey] = { en: nextNode[labelKey] };
|
||||||
|
}
|
||||||
|
nextNode[labelKey] = { ...(nextNode[labelKey] || {}), ...nextNode.translations };
|
||||||
|
delete nextNode.translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentId || parentId === "root") {
|
||||||
|
payload.push(nextNode);
|
||||||
|
} else {
|
||||||
|
const found = findNodeAndParent(payload, parentId);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Parent node ${parentId} not found`);
|
||||||
|
}
|
||||||
|
const children = getChildrenArray(found.node, found.parent === null);
|
||||||
|
children.push(nextNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, warnings, nodeId: nextNode.nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMenuUpdateTranslation(tree: any[], nodeId: string, translations: any): MenuTreeMutationResult {
|
||||||
|
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
|
||||||
|
const found = findNodeAndParent(payload, nodeId);
|
||||||
|
if (!found) throw new Error(`Node ${nodeId} not found`);
|
||||||
|
const labelKey = found.node.title !== undefined ? "title" : "label";
|
||||||
|
found.node[labelKey] = { ...(found.node[labelKey] || {}), ...translations };
|
||||||
|
return { payload, warnings: [], nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMenuMoveNode(tree: any[], nodeId: string, newParentId?: string, orderIndex?: number): MenuTreeMutationResult {
|
||||||
|
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
|
||||||
|
const found = findNodeAndParent(payload, nodeId);
|
||||||
|
if (!found) throw new Error(`Node ${nodeId} not found`);
|
||||||
|
found.list.splice(found.index, 1);
|
||||||
|
|
||||||
|
let targetList = payload;
|
||||||
|
if (newParentId && newParentId !== "root") {
|
||||||
|
const newParent = findNodeAndParent(payload, newParentId);
|
||||||
|
if (!newParent) throw new Error(`New parent ${newParentId} not found`);
|
||||||
|
targetList = getChildrenArray(newParent.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof orderIndex === "number" && orderIndex >= 0) {
|
||||||
|
targetList.splice(orderIndex, 0, found.node);
|
||||||
|
} else {
|
||||||
|
targetList.push(found.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, warnings: [], nodeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMenuRemoveNode(tree: any[], nodeId: string): MenuTreeMutationResult {
|
||||||
|
const payload = cloneMenuValue(ensureMenuPayloadArray(tree));
|
||||||
|
const found = findNodeAndParent(payload, nodeId);
|
||||||
|
if (!found) throw new Error(`Node ${nodeId} not found`);
|
||||||
|
found.list.splice(found.index, 1);
|
||||||
|
return { payload, warnings: [], removedNode: describeMenuNode(found.node) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMenuPayloadForWrite(payload: any) {
|
||||||
|
const cloned = cloneMenuValue(ensureMenuPayloadArray(payload));
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
const walk = (value: any) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(walk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) return;
|
||||||
|
|
||||||
|
if (!isStructuralMenuNode(value)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, "nodeId")) {
|
||||||
|
delete value.nodeId;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.nodeId) {
|
||||||
|
value.nodeId = crypto.randomUUID();
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of MENU_NODE_ARRAY_KEYS) {
|
||||||
|
if (Array.isArray(value[key])) {
|
||||||
|
walk(value[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(cloned);
|
||||||
|
return { payload: cloned, modified };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMenuSnapshot(menu: any) {
|
||||||
|
const snapshotId = crypto.randomUUID();
|
||||||
|
const payload = cloneMenuValue(ensureMenuPayloadArray(menu?.payload));
|
||||||
|
const items = cloneMenuValue(Array.isArray(menu?.items) ? menu.items : []);
|
||||||
|
const snapshot = {
|
||||||
|
name: menu?.name || "main",
|
||||||
|
payload,
|
||||||
|
items,
|
||||||
|
isActive: menu?.isActive,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
menuSnapshots.set(snapshotId, snapshot);
|
||||||
|
return { snapshotId, ...snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreMenuSnapshot(snapshotId: string) {
|
||||||
|
const snapshot = menuSnapshots.get(snapshotId);
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error(`Snapshot ${snapshotId} not found`);
|
||||||
|
}
|
||||||
|
return cloneMenuValue(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulkMenuOperations(tree: any[], operations: any[]) {
|
||||||
|
let payload = cloneMenuValue(ensureMenuPayloadArray(tree));
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const applied: any[] = [];
|
||||||
|
|
||||||
|
for (const [index, operation] of operations.entries()) {
|
||||||
|
if (!isPlainObject(operation)) {
|
||||||
|
throw new Error(`operations[${index}] must be a plain object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: MenuTreeMutationResult;
|
||||||
|
|
||||||
|
switch (operation.type) {
|
||||||
|
case "add_node": {
|
||||||
|
const validation = validateMenuNodeData(operation.nodeData, operation.parentId);
|
||||||
|
if (validation.issues.length > 0) {
|
||||||
|
throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`);
|
||||||
|
}
|
||||||
|
result = applyMenuAddNode(payload, operation.parentId, operation.nodeData);
|
||||||
|
warnings.push(...validation.warnings);
|
||||||
|
applied.push({ type: operation.type, nodeId: result.nodeId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update_translation": {
|
||||||
|
const validation = validateMenuTranslations(operation.translations);
|
||||||
|
if (validation.issues.length > 0) {
|
||||||
|
throw new Error(`operations[${index}]: ${validation.issues.join("; ")}`);
|
||||||
|
}
|
||||||
|
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
|
||||||
|
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
|
||||||
|
}
|
||||||
|
result = applyMenuUpdateTranslation(payload, operation.nodeId, operation.translations);
|
||||||
|
warnings.push(...validation.warnings);
|
||||||
|
applied.push({ type: operation.type, nodeId: result.nodeId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "move_node": {
|
||||||
|
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
|
||||||
|
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
|
||||||
|
}
|
||||||
|
result = applyMenuMoveNode(payload, operation.nodeId, operation.newParentId, operation.orderIndex);
|
||||||
|
applied.push({ type: operation.type, nodeId: result.nodeId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove_node": {
|
||||||
|
if (typeof operation.nodeId !== "string" || !operation.nodeId) {
|
||||||
|
throw new Error(`operations[${index}].nodeId must be a non-empty string`);
|
||||||
|
}
|
||||||
|
result = applyMenuRemoveNode(payload, operation.nodeId);
|
||||||
|
applied.push({ type: operation.type, removedNode: result.removedNode });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`operations[${index}].type must be one of add_node, update_translation, move_node, remove_node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = result.payload;
|
||||||
|
warnings.push(...result.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeMenuPayloadForWrite(payload);
|
||||||
|
return { payload: normalized.payload, warnings, applied, normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
MENU_NODE_ARRAY_KEYS,
|
||||||
|
MENU_NODE_VALUE_KEYS,
|
||||||
|
type MenuTreeMutationResult,
|
||||||
|
applyBulkMenuOperations,
|
||||||
|
applyMenuAddNode,
|
||||||
|
applyMenuMoveNode,
|
||||||
|
applyMenuRemoveNode,
|
||||||
|
applyMenuUpdateTranslation,
|
||||||
|
cloneMenuValue,
|
||||||
|
collectMenuLinksFromTree,
|
||||||
|
createMenuSnapshot,
|
||||||
|
describeMenuNode,
|
||||||
|
ensureMenuPayloadArray,
|
||||||
|
findNodeAndParent,
|
||||||
|
getChildrenArray,
|
||||||
|
isPlainObject,
|
||||||
|
isStructuralMenuNode,
|
||||||
|
normalizeMenuPayloadForWrite,
|
||||||
|
restoreMenuSnapshot,
|
||||||
|
validateMenuItems,
|
||||||
|
validateMenuNodeData,
|
||||||
|
validateMenuTranslations,
|
||||||
|
validateMenuTreeSnapshot,
|
||||||
|
};
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import type { AxiosInstance } from "axios";
|
||||||
|
import { PAGE_SECTION_TYPES } from "@homaroma/types";
|
||||||
|
|
||||||
|
const localizedStringSchema = {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "string" },
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizedStringListSchema = {
|
||||||
|
type: "array",
|
||||||
|
items: localizedStringSchema
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
||||||
|
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
||||||
|
|
||||||
|
const SECTION_TOOL_CONFIGS = [
|
||||||
|
{
|
||||||
|
name: "update_hero_article",
|
||||||
|
sectionType: "HERO_ARTICLE",
|
||||||
|
description: `Update a HERO_ARTICLE section. Use localized objects for title, subtitle, author, and category. Use plain strings for image URLs, overlayColor, publishDate/date, and buttonLink. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample}, "imageUrl": "https://..." }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
|
||||||
|
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
|
||||||
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
||||||
|
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
|
||||||
|
publishDate: { type: "string", description: "Plain string publish date or display date" },
|
||||||
|
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
|
||||||
|
imageUrl: { type: "string", description: "Plain string main image URL" },
|
||||||
|
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
|
||||||
|
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_rich_text",
|
||||||
|
sectionType: "RICH_TEXT",
|
||||||
|
description: `Update a RICH_TEXT section. Title is optional and localized. Content is required and may be HTML or plain text per language. Example payload: { "sectionId": 34, "title": ${localizedStringExample}, "content": ${localizedStringExample} }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
|
||||||
|
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
|
||||||
|
},
|
||||||
|
required: ["sectionId", "content"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_scent_notes_breakdown",
|
||||||
|
sectionType: "SCENT_NOTES_BREAKDOWN",
|
||||||
|
description: `Update a SCENT_NOTES_BREAKDOWN section. Title and description are localized. Each note item can be a plain string or a localized object. Use either heartNotes or middleNotes; the backend mirrors whichever one you provide so both stay in sync. Example payload: { "sectionId": 40, "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
||||||
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
||||||
|
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
|
||||||
|
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
||||||
|
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
||||||
|
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_quote_callout",
|
||||||
|
sectionType: "QUOTE_CALLOUT",
|
||||||
|
description: `Update a QUOTE_CALLOUT section. Quote is required and localized. Author and role are optional localized fields. Example payload: { "sectionId": 48, "quote": ${localizedStringExample}, "author": ${localizedStringExample}, "role": ${localizedStringExample} }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
|
||||||
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
||||||
|
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_product_carousel",
|
||||||
|
sectionType: "PRODUCT_CAROUSEL",
|
||||||
|
description: `Update a PRODUCT_CAROUSEL section. Title and description are localized. collectionId, limit, showPrice, showAddToCart, and products are plain values. If products is omitted, backend can auto-populate products on read-back using collectionId/type/productIds/limit. Example payload: { "sectionId": 52, "title": ${localizedStringExample}, "collectionId": "summer", "limit": 6, "showPrice": true }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
||||||
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
||||||
|
collectionId: { type: "string", description: "Optional plain string collection identifier" },
|
||||||
|
limit: { type: "number", description: "Maximum number of products to show" },
|
||||||
|
showPrice: { type: "boolean", description: "Show product price" },
|
||||||
|
showAddToCart: { type: "boolean", description: "Show add to cart button" },
|
||||||
|
products: { type: "array", description: "Optional explicit product list. Omit this to let backend auto-populate on read-back.", items: { type: "object" } }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_accordion",
|
||||||
|
sectionType: "ACCORDION",
|
||||||
|
description: `Update an ACCORDION section. title is optional and localized. categories is required. Each category needs a localized name, optional icon, and at least one question. Each question needs localized q and a fields. Example payload: { "sectionId": 60, "categories": [ { "name": ${localizedStringExample}, "questions": [ { "q": ${localizedStringExample}, "a": ${localizedStringExample} } ] } ] }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
||||||
|
categories: {
|
||||||
|
type: "array",
|
||||||
|
description: "Accordion categories",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
|
||||||
|
icon: { type: "string", description: "Optional icon or emoji" },
|
||||||
|
questions: {
|
||||||
|
type: "array",
|
||||||
|
description: "Questions under the category",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
|
||||||
|
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
|
||||||
|
},
|
||||||
|
required: ["q", "a"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name", "questions"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["sectionId", "categories"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_contact_info",
|
||||||
|
sectionType: "CONTACT_INFO",
|
||||||
|
description: `Update a CONTACT_INFO section. title is optional and localized. address and hours are localized text. email and line are plain strings. Example payload: { "sectionId": 72, "title": ${localizedStringExample}, "address": ${localizedStringExample}, "email": "hello@example.com", "line": "@homaroma", "hours": ${localizedStringExample} }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
|
||||||
|
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
|
||||||
|
email: { type: "string", description: "Plain string email address" },
|
||||||
|
line: { type: "string", description: "Plain string Line ID or contact" },
|
||||||
|
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
|
||||||
|
},
|
||||||
|
required: ["sectionId", "address", "email", "line", "hours"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_contact_form",
|
||||||
|
sectionType: "CONTACT_FORM",
|
||||||
|
description: `Update a CONTACT_FORM section. title and subtitle are localized; this block has no extra fields. Example payload: { "sectionId": 73, "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`,
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
|
||||||
|
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionToolConfig = (typeof SECTION_TOOL_CONFIGS)[number];
|
||||||
|
|
||||||
|
const SECTION_TOOL_CONFIG_BY_NAME = new Map<string, SectionToolConfig>(
|
||||||
|
SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildSectionPayload(params: any, fields: readonly string[]) {
|
||||||
|
const payload: Record<string, any> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (params[field] !== undefined) {
|
||||||
|
payload[field] = params[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (sectionType === "SCENT_NOTES_BREAKDOWN") {
|
||||||
|
notes.push("heartNotes and middleNotes are mirrored automatically by the backend");
|
||||||
|
}
|
||||||
|
if (sectionType === "PRODUCT_CAROUSEL") {
|
||||||
|
notes.push("If products was omitted, the backend may populate products on read-back");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tool: toolName,
|
||||||
|
sectionType,
|
||||||
|
sectionId: updatedSection?.id,
|
||||||
|
updatedFields: Object.keys(payload),
|
||||||
|
appliedPayload: payload,
|
||||||
|
notes,
|
||||||
|
section: updatedSection
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECTION_TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
name: "add_page_section",
|
||||||
|
description: "Add a block section to a dynamic page layout",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pageId: { type: "number", description: "The numeric ID of the page" },
|
||||||
|
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
|
||||||
|
order: { type: "number", description: "Display order sequence (starts at 1)" },
|
||||||
|
payload: { type: "object", description: "The JSON payload matching the section type rules" },
|
||||||
|
isActive: { type: "boolean", description: "Initial active state" }
|
||||||
|
},
|
||||||
|
required: ["pageId", "type", "payload"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_page_section",
|
||||||
|
description: "Update an existing block section payload, order or active state",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Optional new block type" },
|
||||||
|
order: { type: "number", description: "Optional new display order sequence" },
|
||||||
|
payload: { type: "object", description: "Optional new JSON payload matching the section type rules" },
|
||||||
|
isActive: { type: "boolean", description: "Optional new active state" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...SECTION_TOOL_CONFIGS.map((config) => ({
|
||||||
|
name: config.name,
|
||||||
|
description: config.description,
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: config.properties,
|
||||||
|
required: config.required
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "delete_page_section",
|
||||||
|
description: "Delete a block section from a dynamic page layout",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to delete" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SECTION_TOOL_NAMES = new Set(SECTION_TOOL_DEFINITIONS.map((tool) => tool.name));
|
||||||
|
|
||||||
|
export async function handleSectionTool(api: AxiosInstance, name: string, params: any) {
|
||||||
|
if (!SECTION_TOOL_NAMES.has(name)) return null;
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case "add_page_section": {
|
||||||
|
const payload: any = {
|
||||||
|
type: params.type,
|
||||||
|
payload: params.payload,
|
||||||
|
isActive: params.isActive !== false
|
||||||
|
};
|
||||||
|
if (params.order !== undefined) payload.order = params.order;
|
||||||
|
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "update_page_section": {
|
||||||
|
const payload: any = {};
|
||||||
|
if (params.type) payload.type = params.type;
|
||||||
|
if (params.order !== undefined) payload.order = params.order;
|
||||||
|
if (params.payload) payload.payload = params.payload;
|
||||||
|
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
||||||
|
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
||||||
|
return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload);
|
||||||
|
}
|
||||||
|
case "update_hero_article":
|
||||||
|
case "update_rich_text":
|
||||||
|
case "update_scent_notes_breakdown":
|
||||||
|
case "update_quote_callout":
|
||||||
|
case "update_product_carousel":
|
||||||
|
case "update_accordion":
|
||||||
|
case "update_contact_info":
|
||||||
|
case "update_contact_form": {
|
||||||
|
const config = SECTION_TOOL_CONFIG_BY_NAME.get(name);
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Unknown tool config for: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
||||||
|
if (config.sectionType === "SCENT_NOTES_BREAKDOWN") {
|
||||||
|
if (payload.heartNotes !== undefined && payload.middleNotes === undefined) {
|
||||||
|
payload.middleNotes = payload.heartNotes;
|
||||||
|
} else if (payload.middleNotes !== undefined && payload.heartNotes === undefined) {
|
||||||
|
payload.heartNotes = payload.middleNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await api.put(`/pages/sections/${params.sectionId}`, {
|
||||||
|
type: config.sectionType,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildSectionUpdateResult(name, config.sectionType, res.data, payload);
|
||||||
|
}
|
||||||
|
case "delete_page_section": {
|
||||||
|
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-2xl font-bold tracking-widest">
|
<div className="text-2xl font-bold tracking-widest">
|
||||||
{brandName} <span className="text-cyan-glow text-sm font-light italic">Essential</span>
|
{brandName}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 text-sm leading-relaxed">
|
<p className="text-slate-500 text-sm leading-relaxed">
|
||||||
{settings?.footerDescription || t('footer.catchphrase')}
|
{settings?.footerDescription || t('footer.catchphrase')}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
{/* Left: Logo */}
|
{/* Left: Logo */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0">
|
||||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0">
|
||||||
{brandName} <span className="hidden md:inline text-spa-stone text-xs md:text-sm not-italic font-light ml-1">Essential</span>
|
{brandName}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user