From 62db477dbd1aacf43aa497be6da906cf425c33f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 18:42:39 -0400 Subject: [PATCH] x --- ADMIN_MCP_SECTION_GUIDE.md | 220 ++++++++++++++++++ LLM_MCP_GUIDE.md | 140 +++++++++++ apps/backend/src/routes/pages.js | 48 +++- apps/backend/src/routes/pages.ts | 106 ++++++++- apps/mcp-server/index.ts | 141 +++++++---- .../src/components/ClientProviders.tsx | 12 +- apps/website/src/components/Footer.tsx | 19 +- apps/website/src/components/Navbar.tsx | 21 +- apps/website/src/components/ProductCard.tsx | 2 - apps/website/src/hooks/useLang.ts | 33 ++- apps/website/src/lib/i18n.ts | 5 +- mcp_update.md | 8 + packages/types/index.ts | 3 +- 13 files changed, 660 insertions(+), 98 deletions(-) create mode 100644 ADMIN_MCP_SECTION_GUIDE.md create mode 100644 LLM_MCP_GUIDE.md diff --git a/ADMIN_MCP_SECTION_GUIDE.md b/ADMIN_MCP_SECTION_GUIDE.md new file mode 100644 index 0000000..2295cd8 --- /dev/null +++ b/ADMIN_MCP_SECTION_GUIDE.md @@ -0,0 +1,220 @@ +# HomAroma Admin Section Guide + +This guide explains the dynamic page blocks available in the Page Builder and what each block contains. + +## Field Shape Legend + +- `localized string` = plain text or `{ th, en, ja, zh, vi, ko }` +- `plain string` = one string only +- `optional` = can be omitted + +## Available Blocks + +### `HERO` + +- Purpose: Main hero banner for a page +- Contains: + - `tagline` localized string + - `title` localized string + - `description` localized string + - `imageUrl` plain string + - `buttonText` localized string + - `buttonLink` plain string +- Best for: Landing pages, featured campaigns, page introductions + +### `BANNER` + +- Purpose: Image banner with optional CTA +- Contains: + - `imageUrl` plain string + - `linkUrl` plain string + - `title` localized string optional + - `description` localized string optional + - `buttonText` localized string optional + +### `TEXT` + +- Purpose: Simple content block +- Contains: + - `title` localized string optional + - `content` localized string + - `textAlign` plain string optional: `left`, `center`, `right` + +### `ANNOUNCEMENT_BAR` + +- Purpose: Rotating announcement strip +- Contains: + - `slides` array of localized strings + - `backgroundColor` plain string optional + - `textColor` plain string optional + - `speed` number optional + +### `NEWSLETTER` + +- Purpose: Newsletter signup panel +- Contains: + - `title` localized string + - `description` localized string + - `buttonText` localized string + - `placeholderText` plain string optional + - `showPrivacyCheckbox` boolean + - `privacyText` plain string optional + +### `EDITORIAL_TILE` + +- Purpose: Side-by-side editorial story tile +- Contains: + - `title` localized string + - `description` localized string + - `imageUrl` plain string + - `imagePosition` plain string: `left` or `right` + - `buttonText` plain string optional + - `buttonLink` plain string optional + - `backgroundColor` plain string optional + - `textColor` plain string optional + +### `EDITORIAL_CAROUSEL` + +- Purpose: Multi-slide editorial storytelling block +- Contains: + - `title` localized string + - `slides` array of: + - `category` localized string + - `title` localized string + - `description` localized string + - `imageUrl` plain string + - `buttonText` plain string optional + - `buttonLink` plain string optional + +### `PRODUCTS` + +- Purpose: Product list/grid section +- Contains: + - `title` localized string + - `subtitle` localized string optional + - `type` plain string: `featured`, `scent`, or `custom` + - `scentId` number optional + - `productIds` number array optional + - `limit` number optional + - `layout` plain string optional: `grid` or `carousel` + - `enableQuickShop` boolean optional + +### `CATEGORIES` + +- Purpose: Category showcase block +- Contains: + - `title` localized string + - `subtitle` localized string optional + - `categoryIds` number array optional + +### `HERO_ARTICLE` + +- Purpose: Editorial hero/article header +- Contains: + - `title` localized string + - `subtitle` localized string optional + - `author` localized string optional + - `category` localized string optional + - `publishDate` plain string optional + - `date` plain string optional + - `imageUrl` plain string + - `backgroundImageUrl` plain string optional + - `overlayColor` plain string optional +- Notes: + - The image is set by entering an image URL in `imageUrl` + - In the Admin UI, the image picker stores the URL for you + +### `RICH_TEXT` + +- Purpose: Flexible HTML/text content block +- Contains: + - `title` localized string optional + - `content` localized string required +- Notes: + - HTML is allowed + - Use language tabs to translate each version + +### `SCENT_NOTES_BREAKDOWN` + +- Purpose: Fragrance layer breakdown +- Contains: + - `title` localized string optional + - `description` localized string optional + - `topNotes` array of strings or localized string objects + - `heartNotes` array of strings or localized string objects + - `middleNotes` array of strings or localized string objects + - `baseNotes` array of strings or localized string objects +- Notes: + - `heartNotes` and `middleNotes` are mirrored together by the backend + +### `QUOTE_CALLOUT` + +- Purpose: Highlighted quote block +- Contains: + - `quote` localized string required + - `author` localized string optional + - `role` localized string optional + +### `PRODUCT_CAROUSEL` + +- Purpose: Featured product carousel +- Contains: + - `title` localized string optional + - `description` localized string optional + - `collectionId` plain string optional + - `limit` number optional + - `showPrice` boolean optional + - `showAddToCart` boolean optional + - `products` optional explicit product list +- Notes: + - If `products` is omitted, the backend can auto-populate products on read-back + +### `ACCORDION` + +- Purpose: FAQ-style expandable block +- Contains: + - `title` localized string optional + - `categories` required array + - each category has: + - `name` localized string + - `icon` plain string optional + - `questions` required array + - each question has: + - `q` localized string + - `a` localized string + +### `CONTACT_INFO` + +- Purpose: Contact details block +- Contains: + - `title` localized string optional + - `address` localized string required + - `email` plain string required + - `line` plain string required + - `hours` localized string required + +### `CONTACT_FORM` + +- Purpose: Contact form header block +- Contains: + - `title` localized string optional + - `subtitle` localized string optional +- Notes: + - This block has no extra fields beyond title and subtitle + +## Recommended Admin Workflow + +1. Create or open a page. +2. Add blocks in the order you want them rendered. +3. Fill localized fields per language. +4. Use the media picker or image URL fields for image-based blocks. +5. Save the block, then verify the rendered page. + +## Quick Tips + +- Use `HERO_ARTICLE` for editorial-style stories. +- Use `RICH_TEXT` for long-form content. +- Use `SCENT_NOTES_BREAKDOWN` for fragrance layer storytelling. +- Use `PRODUCT_CAROUSEL` when you want featured products without manually listing each one. +- Use `ACCORDION` for FAQ content. +- Use `CONTACT_INFO` and `CONTACT_FORM` together for contact pages. diff --git a/LLM_MCP_GUIDE.md b/LLM_MCP_GUIDE.md new file mode 100644 index 0000000..dc55805 --- /dev/null +++ b/LLM_MCP_GUIDE.md @@ -0,0 +1,140 @@ +# HomAroma MCP Guide for LLMs + +This guide explains how to use the HomAroma MCP tools safely and consistently. + +## Core Rules + +- Prefer the dedicated block tools over the generic `add_page_section` / `update_page_section` tools when available. +- Use `update_page_translations` when you need to set several languages for a page title in one call. +- Treat localized fields and plain strings differently. +- Read back the page after edits when you need confirmation. + +## Finding a Page + +- Use `get_dynamic_page_detail` with the page slug when you already know the slug. +- Use `list_dynamic_pages_metadata` when you need to search all pages quickly. +- The dynamic page detail response includes the page `id`, which is the `pageId` you use for section tools. + +## Create Page Response Contract + +`create_dynamic_page` now returns a structured response that is easy for LLMs to parse: + +- `success` +- `operation` +- `pageId` +- `slug` +- `title` +- `isPublished` +- `diagnostic` +- `page` + +Use `diagnostic` as the human-readable explanation, not as the only signal of success. + +## Section Tool Mapping + +- `update_hero_article` +- `update_rich_text` +- `update_scent_notes_breakdown` +- `update_quote_callout` +- `update_product_carousel` +- `update_accordion` +- `update_contact_info` +- `update_contact_form` + +These tools return a normalized response with: + +- `success` +- `tool` +- `sectionType` +- `sectionId` +- `updatedFields` +- `appliedPayload` +- `notes` +- `section` + +## Payload Shape Cheat Sheet + +### Localized string + +Use this when a field can be translated: + +```json +{ "th": "ข้อความภาษาไทย", "en": "English text" } +``` + +### Plain string + +Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate. + +## Block-Specific Rules + +### `HERO_ARTICLE` + +- Localized: `title`, `subtitle`, `author`, `category` +- Plain string: `publishDate`, `date`, `imageUrl`, `backgroundImageUrl`, `overlayColor` +- The image is passed as a URL string in `imageUrl` + +### `RICH_TEXT` + +- `title` is optional and localized +- `content` is required and localized +- HTML is allowed in `content` + +### `SCENT_NOTES_BREAKDOWN` + +- `title` and `description` are localized +- Each note item may be a plain string or a localized string object +- `heartNotes` and `middleNotes` are mirrored by the backend + +### `QUOTE_CALLOUT` + +- `quote` is required and localized +- `author` and `role` are optional localized fields + +### `PRODUCT_CAROUSEL` + +- `title` and `description` are localized +- `collectionId`, `limit`, `showPrice`, `showAddToCart`, and `products` are plain values +- If `products` is omitted, the backend can auto-populate products on read-back + +### `ACCORDION` + +- `title` is optional and localized +- `categories` is required +- Each category needs: + - localized `name` + - optional `icon` + - at least one question +- Each question needs localized `q` and `a` + +### `CONTACT_INFO` + +- `title` is optional and localized +- `address` and `hours` are localized +- `email` and `line` are plain strings + +### `CONTACT_FORM` + +- `title` and `subtitle` are localized +- There are no extra fields + +## Recommended Flow + +1. Find the page id from slug or metadata. +2. Create or update the page title translations. +3. Add sections using the dedicated block tool when possible. +4. Read back the page with `get_dynamic_page_detail`. +5. If a section has auto-populated fields, trust the read-back result over the raw update payload. + +## What to Expect + +- `PRODUCT_CAROUSEL` may return products even when you did not send them. +- `SCENT_NOTES_BREAKDOWN` may show mirrored note groups in the response. +- `create_dynamic_page` should now return a clear success/error contract. + +## Safe Usage Tips + +- Do not guess the field shape from the UI alone. +- If a field is a URL, send a URL string. +- If a field is translated, send a localized object. +- If you are unsure, inspect an existing page with `get_dynamic_page_detail` first. diff --git a/apps/backend/src/routes/pages.js b/apps/backend/src/routes/pages.js index b04d153..482075b 100644 --- a/apps/backend/src/routes/pages.js +++ b/apps/backend/src/routes/pages.js @@ -169,12 +169,21 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) }) .use(adminOnly) .post("/", async ({ body, set }) => { + const data = body; + const operation = data?.id ? "update" : "create"; try { - const data = body; const { id, slug, title, isPublished, status, metadata } = data; if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) { set.status = 400; - return { error: "Reserved slug cannot be used" }; + return { + success: false, + operation, + pageId: id ? Number(id) : null, + slug, + title, + error: "Reserved slug cannot be used", + diagnostic: "The requested slug is reserved and cannot be used for dynamic pages." + }; } let finalIsPublished = true; if (status !== undefined) { @@ -183,22 +192,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) else if (isPublished !== undefined) { finalIsPublished = isPublished; } - if (id) { - return await prisma.page.update({ + const page = id + ? await prisma.page.update({ where: { id: Number(id) }, data: { slug, title, isPublished: finalIsPublished } - }); - } - else { - return await prisma.page.create({ + }) + : await prisma.page.create({ data: { slug, title, isPublished: finalIsPublished } }); - } + return { + success: true, + operation, + pageId: page.id, + slug: page.slug, + title: page.title, + isPublished: page.isPublished, + page, + diagnostic: operation === "create" + ? "Page created successfully." + : "Page updated successfully." + }; } catch (err) { console.error("[Pages] Error:", err); set.status = 500; - return { error: "Failed to save page" }; + return { + success: false, + operation, + pageId: data?.id ? Number(data.id) : null, + slug: data?.slug, + title: data?.title, + error: "Failed to save page", + diagnostic: err instanceof Error ? err.message : "Unexpected error while saving page." + }; } }, { body: t.Object({ @@ -479,4 +505,4 @@ function validateSectionPayload(type, payload) { } return null; } -//# sourceMappingURL=pages.js.map \ No newline at end of file +//# sourceMappingURL=pages.js.map diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index c414030..e6cd1de 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -248,6 +248,66 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details }; } }); + } else if (section.type === 'PRODUCT_CAROUSEL' && !Array.isArray(populatedPayload.products)) { + let products: any[] = []; + const limit = payload.limit || 3; + + const productInclude = { + category: true, + scent: true, + translations: true, + variants: { + include: { + bomItems: { include: { rawMaterial: true } } + }, + orderBy: { price: "asc" as const } + }, + images: { + orderBy: { order: "asc" as const } + } + }; + + if (payload.type === 'featured' || payload.collectionId === 'featured') { + products = await prisma.product.findMany({ + where: { categoryId: 1 }, + include: productInclude, + take: limit + }); + } else if (payload.type === 'scent' && payload.scentId) { + products = await prisma.product.findMany({ + where: { scentId: Number(payload.scentId) }, + include: productInclude, + take: limit + }); + } else if (Array.isArray(payload.productIds)) { + const ids = payload.productIds.map(Number); + products = await prisma.product.findMany({ + where: { id: { in: ids } }, + include: productInclude + }); + products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + } else { + products = await prisma.product.findMany({ + include: productInclude, + take: limit + }); + } + + populatedPayload.products = products.map((p: any) => { + if (lang === 'all') { + const nameObj: any = {}; + const descObj: any = {}; + const detailsObj: any = {}; + p.translations?.forEach((t: any) => { + nameObj[t.langCode] = t.name; + descObj[t.langCode] = t.description; + detailsObj[t.langCode] = t.details; + }); + return { ...p, name: nameObj, description: descObj, details: detailsObj }; + } + const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0]; + return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details }; + }); } else if (section.type === 'CATEGORIES') { let categories: any[] = []; if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) { @@ -328,13 +388,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) }) .use(adminOnly) .post("/", async ({ body, set }) => { + const data = body as any; + const operation = data?.id ? "update" : "create"; try { - const data = body as any; const { id, slug, title, isPublished, status, metadata } = data; if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) { set.status = 400; - return { error: "Reserved slug cannot be used" }; + return { + success: false, + operation, + pageId: id ? Number(id) : null, + slug, + title, + error: "Reserved slug cannot be used", + diagnostic: "The requested slug is reserved and cannot be used for dynamic pages." + }; } let finalIsPublished = true; @@ -344,20 +413,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" }) finalIsPublished = isPublished; } - if (id) { - return await prisma.page.update({ + const page = id + ? await prisma.page.update({ where: { id: Number(id) }, data: { slug, title, isPublished: finalIsPublished } - }); - } else { - return await prisma.page.create({ + }) + : await prisma.page.create({ data: { slug, title, isPublished: finalIsPublished } }); - } + + return { + success: true, + operation, + pageId: page.id, + slug: page.slug, + title: page.title, + isPublished: page.isPublished, + page, + diagnostic: operation === "create" + ? "Page created successfully." + : "Page updated successfully." + }; } catch (err) { console.error("[Pages] Error:", err); set.status = 500; - return { error: "Failed to save page" }; + return { + success: false, + operation, + pageId: data?.id ? Number(data.id) : null, + slug: data?.slug, + title: data?.title, + error: "Failed to save page", + diagnostic: err instanceof Error ? err.message : "Unexpected error while saving page." + }; } }, { body: t.Object({ diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 15286ab..b723995 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -55,92 +55,96 @@ const localizedStringListSchema = { 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 with structured editorial fields.", + 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" }, - subtitle: { ...localizedStringSchema, description: "Localized subtitle" }, - author: { ...localizedStringSchema, description: "Localized author name" }, - category: { ...localizedStringSchema, description: "Localized category or tag" }, - publishDate: { type: "string", description: "Publish date or display date" }, - date: { type: "string", description: "Alternative date field accepted by backend" }, - imageUrl: { type: "string", description: "Main image URL" }, - backgroundImageUrl: { type: "string", description: "Background image URL" }, - overlayColor: { type: "string", description: "Overlay color in hex or rgba" } + 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 with localized title and content.", + 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" }, - content: { ...localizedStringSchema, description: "Localized HTML or plain text content" } + 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 with localized title, description, and note groups.", + 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" }, - description: { ...localizedStringSchema, description: "Localized supporting description" }, - topNotes: { type: "array", items: localizedStringSchema, description: "Top note values" }, - heartNotes: { type: "array", items: localizedStringSchema, description: "Heart note values" }, - middleNotes: { type: "array", items: localizedStringSchema, description: "Middle note values" }, - baseNotes: { type: "array", items: localizedStringSchema, description: "Base note values" } + 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 with a localized quote, author, and role.", + 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" }, - author: { ...localizedStringSchema, description: "Localized author name" }, - role: { ...localizedStringSchema, description: "Localized author role or title" } + 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 with localized copy and carousel options.", + 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" }, - description: { ...localizedStringSchema, description: "Localized supporting description" }, - collectionId: { type: "string", description: "Optional collection identifier" }, + 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" } + 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 with categories, questions, and localized answers.", + 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" }, + 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" }, + name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` }, icon: { type: "string", description: "Optional icon or emoji" }, questions: { type: "array", @@ -148,8 +152,8 @@ const SECTION_TOOL_CONFIGS = [ items: { type: "object", properties: { - q: { ...localizedStringSchema, description: "Localized question" }, - a: { ...localizedStringSchema, description: "Localized answer" } + q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` }, + a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` } }, required: ["q", "a"] } @@ -164,25 +168,25 @@ const SECTION_TOOL_CONFIGS = [ { name: "update_contact_info", sectionType: "CONTACT_INFO", - description: "Update a CONTACT_INFO section with localized address, email, line, and hours.", + 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" }, - address: { ...localizedStringSchema, description: "Localized address" }, - email: { ...localizedStringSchema, description: "Localized email" }, - line: { ...localizedStringSchema, description: "Localized Line ID or contact" }, - hours: { ...localizedStringSchema, description: "Localized business hours" } + 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 with localized title and subtitle.", + 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" }, - subtitle: { ...localizedStringSchema, description: "Localized form subtitle" } + title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` }, + subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` } }, required: ["sectionId"] } @@ -203,6 +207,13 @@ function buildSectionPayload(params: any, fields: readonly string[]) { } function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record) { + 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: [ { @@ -214,6 +225,8 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated sectionType, sectionId: updatedSection?.id, updatedFields: Object.keys(payload), + appliedPayload: payload, + notes, section: updatedSection }, null, @@ -404,6 +417,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["id", "lang", "title"] } }, + { + name: "update_page_translations", + description: "Add or update multiple page title translations in one call. Use this when you want to set several languages at once instead of calling update_page_translation repeatedly.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Page ID" }, + translations: { + type: "object", + description: "Language-code map for the page title, for example { \"th\": \"...\", \"en\": \"...\" }. Each key becomes or updates one locale." + } + }, + required: ["id", "translations"] + } + }, { name: "update_hero_translation", description: "Add or update translations for a specific hero.", @@ -827,6 +855,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "update_page_translations": { + const { id, translations } = params as any; + const res = await api.patch(`/pages/${id}/translations`, { + title: translations + }); + return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? res.data }, null, 2) }] }; + } case "update_hero_translation": { const { id, lang, tagline, title, description, buttonText } = params as any; const payload: any = {}; @@ -984,7 +1019,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { payload.id = params.id; } const res = await api.post("/pages", payload); - return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + const pageData = res.data?.page ?? res.data; + const normalized = { + success: res.data?.success ?? ((res.status >= 200) && (res.status < 300)), + operation: res.data?.operation ?? (params.id ? "update" : "create"), + pageId: res.data?.pageId ?? pageData?.id ?? null, + slug: res.data?.slug ?? pageData?.slug ?? params.slug, + title: res.data?.title ?? pageData?.title ?? params.title, + isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? true, + diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."), + page: pageData, + }; + return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] }; } case "add_page_section": { const payload: any = { @@ -1019,6 +1065,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } 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 diff --git a/apps/website/src/components/ClientProviders.tsx b/apps/website/src/components/ClientProviders.tsx index f5f453f..f03e350 100644 --- a/apps/website/src/components/ClientProviders.tsx +++ b/apps/website/src/components/ClientProviders.tsx @@ -2,9 +2,19 @@ import '../lib/i18n'; import CartDrawer from './CartDrawer'; import ScrollToTop from './ScrollToTop'; -import { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; +import i18n from '../lib/i18n'; export default function ClientProviders({ children }: { children: ReactNode }) { + useEffect(() => { + const savedLang = localStorage.getItem('homaroma_lang') + + // Hydrate from the user's persisted language only after the first client render. + if (savedLang && savedLang !== i18n.language) { + void i18n.changeLanguage(savedLang) + } + }, []) + return ( <> diff --git a/apps/website/src/components/Footer.tsx b/apps/website/src/components/Footer.tsx index 92c73ae..a99503c 100644 --- a/apps/website/src/components/Footer.tsx +++ b/apps/website/src/components/Footer.tsx @@ -1,4 +1,6 @@ "use client"; + +import { useState, useEffect } from 'react' import Link from 'next/link' import type { Category, MenuItem, StoreSettings } from '@homaroma/types' import { useTranslation } from 'react-i18next' @@ -20,12 +22,17 @@ export default function Footer({ }: FooterProps) { const { t } = useTranslation() const lang = useLang() + const [mounted, setMounted] = useState(false) + useEffect(() => { setMounted(true) }, []) + // Before mount: always 'th' to match SSR output → no hydration mismatch + // After mount: real user language → correct content + const effectiveLang = mounted ? lang : 'th' // Translate a menu item label using its translations array or i18n key map const translateMenuLabel = (item: MenuItem): string => { // Use outer `lang` from closure — hooks cannot be called inside nested functions - if (lang !== 'en' && item.translations && item.translations.length > 0) { - const trans = item.translations.find((tr: any) => tr.langCode === lang) + if (effectiveLang !== 'en' && item.translations && item.translations.length > 0) { + const trans = item.translations.find((tr: any) => tr.langCode === effectiveLang) if (trans?.label) return trans.label } const map: Record = { @@ -56,7 +63,7 @@ export default function Footer({ HomAroma Essential

- {settings?.footerDescription || t('footer.catchphrase')} + {resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}

{hasSocialLinks ? ( @@ -135,7 +142,7 @@ export default function Footer({
{categories.map((c) => ( - {resolveLocalized(c.name)} + {resolveLocalized(c.name, effectiveLang)} ))}
@@ -147,7 +154,7 @@ export default function Footer({ {settings?.footerSupportHeading ? (typeof settings.footerSupportHeading === 'string' ? settings.footerSupportHeading - : settings.footerSupportHeading[lang] + : settings.footerSupportHeading[effectiveLang] || settings.footerSupportHeading.en || 'Support & Legal') : t('footer.support', 'Support & Legal')} @@ -165,7 +172,7 @@ export default function Footer({ {settings?.footerCopyrightText ? (typeof settings.footerCopyrightText === 'string' ? settings.footerCopyrightText - : settings.footerCopyrightText[lang] + : settings.footerCopyrightText[effectiveLang] || settings.footerCopyrightText.en || `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`) : `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`} diff --git a/apps/website/src/components/Navbar.tsx b/apps/website/src/components/Navbar.tsx index 12e5afb..324494c 100644 --- a/apps/website/src/components/Navbar.tsx +++ b/apps/website/src/components/Navbar.tsx @@ -30,13 +30,14 @@ const LANGUAGES = [ ] function createFallbackMenu(categories: Category[]): MegaMenuPayload[] { - const categoryLinks = categories.map((category) => ({ - label: { - th: resolveLocalized(category.name, 'th') || resolveLocalized(category.name), - en: resolveLocalized(category.name, 'en') || resolveLocalized(category.name), - }, - url: `/category/${category.id}`, - })) + const categoryLinks = categories.map((category) => { + // category.name is a multilingual Record when fetched with lang=all + const label: Record = + typeof category.name === 'string' + ? { th: category.name, en: category.name } + : (category.name as Record) + return { label, url: `/category/${category.id}` } + }) return [ { id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' }, @@ -141,7 +142,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp } const iconButtonClass = 'rounded-full p-1.5 text-pop-black/65 transition-all hover:bg-pop-bg hover:text-pop-pink md:p-2' - const activeLanguage = LANGUAGES.find((language) => i18n.language.startsWith(language.code)) ?? LANGUAGES[0] + const activeLanguage = LANGUAGES.find((language) => lang.startsWith(language.code) || language.code.startsWith(lang)) ?? LANGUAGES[0] return ( <> @@ -220,7 +221,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
{LANGUAGES.map((language) => { - const isActive = i18n.language.startsWith(language.code) + const isActive = lang.startsWith(language.code) || language.code.startsWith(lang) return (