diff --git a/apps/backend/scripts/fix-menus.ts b/apps/backend/scripts/fix-menus.ts new file mode 100644 index 0000000..bde2e2b --- /dev/null +++ b/apps/backend/scripts/fix-menus.ts @@ -0,0 +1,54 @@ +import prisma from '../src/lib/prisma'; + +function fixNode(node: any) { + if (node.translations) { + const labelKey = node.title !== undefined ? 'title' : 'label'; + if (typeof node[labelKey] === 'string') { + node[labelKey] = { en: node[labelKey] }; + } + node[labelKey] = { ...(node[labelKey] || {}), ...node.translations }; + delete node.translations; + } + + if (node.columns && Array.isArray(node.columns)) { + node.columns.forEach((col: any) => fixNode(col)); + } + if (node.links && Array.isArray(node.links)) { + node.links.forEach((link: any) => fixNode(link)); + } + if (node.items && Array.isArray(node.items)) { + node.items.forEach((item: any) => fixNode(item)); + } +} + +async function main() { + const menu = await prisma.menu.findUnique({ + where: { name: 'main' } + }); + + if (!menu || !menu.payload) { + console.log('No main menu payload found'); + return; + } + + const payload = menu.payload as any[]; + console.log('Original payload length:', payload.length); + + payload.forEach(fixNode); + + await prisma.menu.update({ + where: { name: 'main' }, + data: { payload } + }); + + console.log('Menu fixed successfully'); +} + +main() + .catch(e => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/backend/scripts/fix-scent-guide.ts b/apps/backend/scripts/fix-scent-guide.ts new file mode 100644 index 0000000..ed1e3e7 --- /dev/null +++ b/apps/backend/scripts/fix-scent-guide.ts @@ -0,0 +1,58 @@ +import prisma from '../src/lib/prisma'; +import crypto from 'crypto'; + +async function main() { + const menu = await prisma.menu.findUnique({ + where: { name: 'main' } + }); + + if (!menu || !menu.payload) { + console.error('Menu main not found or payload is empty'); + return; + } + + const payload = menu.payload as any[]; + let updated = false; + + for (let i = 0; i < payload.length; i++) { + const node = payload[i]; + if (node.nodeId === 'b4b2faf8-8881-4ca9-a8d5-a88054a37385') { + if (node.links && !node.columns) { + console.log('Fixing Scent Guide node...'); + + const newColumn = { + nodeId: crypto.randomUUID(), + title: { + en: 'Essential Oils', + th: 'น้ำมันหอมระเหย', + ja: 'エッセンシャルオイル', + ko: '에센셜 오일', + vi: 'Tinh dầu', + zh: '精油' + }, + links: node.links + }; + + node.columns = [newColumn]; + delete node.links; + updated = true; + } else { + console.log('Scent Guide node already has columns or no links to fix.'); + } + } + } + + if (updated) { + await prisma.menu.update({ + where: { name: 'main' }, + data: { payload } + }); + console.log('Successfully updated main menu payload.'); + } else { + console.log('No updates were necessary.'); + } +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index e90ddb7..437efc4 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -5,9 +6,36 @@ import { ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; -import * as FormData from "form-data"; +import FormData from "form-data"; import { PAGE_SECTION_TYPES } from "@homaroma/types"; +function getChildrenArray(node: any): 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; + 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; +} + let BACKEND_URL = "http://localhost:3051/api"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin"; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123"; // เปลี่ยนรหัสผ่านตรงนี้ให้ตรงกับ Production ด้วยนะครับ @@ -348,6 +376,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: "List all dynamic pages and layouts managed by the CMS", inputSchema: { type: "object", properties: {} } }, + { + name: "list_dynamic_pages_metadata", + description: "List all dynamic pages and layouts managed by the CMS (metadata only)", + inputSchema: { type: "object", properties: {} } + }, + { + name: "get_dynamic_page_detail", + description: "Get full detail of a specific dynamic page by slug", + inputSchema: { + type: "object", + properties: { + slug: { type: "string", description: "Slug of the page" } + }, + required: ["slug"] + } + }, { name: "create_dynamic_page", description: "Create or update a dynamic page or layout", @@ -452,6 +496,62 @@ 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"] + } } ], }; @@ -655,6 +755,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.get("/pages"); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "list_dynamic_pages_metadata": { + const res = await api.get("/pages"); + let metadata = res.data; + if (Array.isArray(metadata)) { + metadata = metadata.map((p: any) => ({ id: p.id, slug: p.slug, title: p.title })); + } else if (metadata && Array.isArray(metadata.data)) { + metadata = { + ...metadata, + data: metadata.data.map((p: any) => ({ id: p.id, slug: p.slug, title: p.title })) + }; + } + return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] }; + } + case "get_dynamic_page_detail": { + const res = await api.get(`/pages/${params.slug}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } case "create_dynamic_page": { const payload: any = { title: params.title, @@ -784,6 +901,125 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { 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); + 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: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/apps/spa/src/components/MobileDrawer.tsx b/apps/spa/src/components/MobileDrawer.tsx index 8ad6ec9..c809c1e 100644 --- a/apps/spa/src/components/MobileDrawer.tsx +++ b/apps/spa/src/components/MobileDrawer.tsx @@ -61,8 +61,8 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe if (view.type === 'root') { return (
- {view.data.map((item) => ( -
+ {view.data.map((item, index) => ( +
{item.columns && item.columns.length > 0 ? (