This commit is contained in:
Your Name committed 2026-06-18 18:42:39 -04:00
1 parent 6f67ee6077
commit 62db477dbd
13 files changed
+659 -97

No files matched your search

+220
View File
@@ -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.
+140
View File
@@ -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.
+36 -10
View File
@@ -169,12 +169,21 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.use(adminOnly)
.post("/", async ({ body, set }) => {
try {
const data = body;
const operation = data?.id ? "update" : "create";
try {
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({
+97 -9
View File
@@ -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 }) => {
try {
const data = body as any;
const operation = data?.id ? "update" : "create";
try {
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({
+97 -44
View File
@@ -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<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: [
{
@@ -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
@@ -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 (
<>
<ScrollToTop />
+13 -6
View File
@@ -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<string, string> = {
@@ -56,7 +63,7 @@ export default function Footer({
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
</div>
<p className="text-sm leading-relaxed text-gray-600">
{settings?.footerDescription || t('footer.catchphrase')}
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
</p>
<div className="flex gap-4">
{hasSocialLinks ? (
@@ -135,7 +142,7 @@ export default function Footer({
<div className="flex flex-col space-y-2 text-sm text-gray-600">
{categories.map((c) => (
<Link key={c.id} href={`/category/${c.id}`} className="transition-colors hover:text-pop-pink">
{resolveLocalized(c.name)}
{resolveLocalized(c.name, effectiveLang)}
</Link>
))}
</div>
@@ -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')}`}
+11 -10
View File
@@ -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<lang,string> when fetched with lang=all
const label: Record<string, string> =
typeof category.name === 'string'
? { th: category.name, en: category.name }
: (category.name as Record<string, string>)
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
<div className="pointer-events-none absolute right-0 top-full w-36 pt-2 opacity-0 transition-all duration-300 group-hover:pointer-events-auto group-hover:opacity-100">
<div className="space-y-0.5 overflow-hidden rounded-xl border border-pop-black/10 bg-white/95 p-1.5 shadow-2xl backdrop-blur-xl">
{LANGUAGES.map((language) => {
const isActive = i18n.language.startsWith(language.code)
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
return (
<button
key={language.code}
@@ -365,7 +366,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
</div>
<div className="space-y-1">
{LANGUAGES.map((language) => {
const isActive = i18n.language.startsWith(language.code)
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
return (
<button
key={language.code}
@@ -4,7 +4,6 @@ import Link from 'next/link'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization'
import { useTranslation } from 'react-i18next'
import { useLang } from '../hooks/useLang'
import WishlistButton from './WishlistButton'
@@ -13,7 +12,6 @@ interface ProductCardProps {
}
export default function ProductCard({ product }: ProductCardProps) {
const { i18n } = useTranslation()
const lang = useLang()
const categoryName = resolveLocalized(product.category.name, lang)
const productName = resolveLocalized(product.name, lang) || product.scent.name
+23 -10
View File
@@ -1,21 +1,34 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import i18n from '../lib/i18n'
/**
* Returns the current i18n language code (2-char) in a hydration-safe way.
* Returns a hydration-safe 2-char language code.
*
* On the server and during initial client render the value is always 'th'
* (matching the SSR output). After hydration it updates to the user's saved
* language so all components re-render with the correct locale without
* triggering a hydration mismatch.
* WHY this pattern is necessary:
* - i18n.ts reads localStorage synchronously at module-load time (before React hydrates)
* - So i18n.language may already be 'ja', 'ko', etc. when client components first render
* - useTranslation() subscribes to this and causes components to render the user's language
* DURING hydration, but the server rendered 'th' — causing a mismatch error
*
* SOLUTION:
* - Always return 'th' until the component has mounted (matching the server)
* - After mount, sync to the real language and subscribe to future changes
* - Components using this hook NEVER cause a hydration mismatch
*/
export function useLang(): string {
const { i18n } = useTranslation()
const [lang, setLang] = useState('th') // must match server default
// Always 'th' on first render (matches SSR) — updated post-hydration
const [lang, setLang] = useState('th')
useEffect(() => {
setLang(i18n.language ? i18n.language.substring(0, 2) : 'th')
}, [i18n.language])
// Sync immediately after hydration
const current = i18n.language ? i18n.language.substring(0, 2) : 'th'
setLang(current)
// Subscribe to future language changes (user clicks language button)
const handler = (lng: string) => setLang(lng.substring(0, 2))
i18n.on('languageChanged', handler)
return () => { i18n.off('languageChanged', handler) }
}, [])
return lang
}
+1 -4
View File
@@ -8,9 +8,6 @@ import zhTranslation from '../locales/zh/translation.json'
import viTranslation from '../locales/vi/translation.json'
import koTranslation from '../locales/ko/translation.json'
// Ensure we only access localStorage on the client side
const savedLang = typeof window !== 'undefined' ? (localStorage.getItem('homaroma_lang') || 'th') : 'th'
// Check if it's already initialized to prevent re-initialization on HMR
if (!i18n.isInitialized) {
i18n
@@ -24,7 +21,7 @@ if (!i18n.isInitialized) {
vi: { translation: viTranslation },
ko: { translation: koTranslation },
},
lng: savedLang, // default language
lng: 'th',
fallbackLng: 'en',
interpolation: {
escapeValue: false, // react already safes from xss
+8
View File
@@ -196,9 +196,14 @@ Translation records ถูกบันทึกจริงในระบบแ
- `update_accordion`
- `update_contact_info`
- `update_contact_form`
- `update_page_translations`
ถ้าจะเพิ่มฝั่ง create แบบเฉพาะ block ต่อไป ค่อยขยายเป็นคู่ `add_*` เพิ่มจากชุดนี้ได้อีกชั้น
> จุดที่ช่วยให้ LLM อ่านง่ายขึ้น: tool แต่ละตัวมี description พร้อมบอกชัดว่า field ไหนเป็น localized object, field ไหนเป็น plain string และมีตัวอย่าง payload ให้ดูทันที
> ฝั่ง `SCENT_NOTES_BREAKDOWN` จะ mirror `heartNotes` กับ `middleNotes` ให้ตรงกันอัตโนมัติ และผลลัพธ์จะบอก note นี้กลับมาใน response ด้วย
> `PRODUCT_CAROUSEL` ตอนนี้ backend จะพยายามเติม `products` ให้อัตโนมัติเมื่อ payload ยังไม่มีรายการสินค้า และ response จะบอกไว้ชัดว่าเกิด auto-populate หรือไม่
### 2) ทำ structured read-back
ตอนนี้ update tools คืนข้อมูลแบบ normalize แล้วใน JSON response
@@ -209,6 +214,9 @@ Translation records ถูกบันทึกจริงในระบบแ
- field ไหนถูก overwrite
- payload สุดท้ายหน้าตาเป็นอะไร
> ตอนนี้ response มี `appliedPayload` เพิ่มมาแล้ว ทำให้ LLM เห็น payload ที่ส่งจริงได้ทันที
> `update_page_translations` ช่วยลดการเรียกซ้ำทีละภาษาได้มาก และคำอธิบายใหม่จะบอกชัดว่าควรใช้เมื่ออยากอัปเดตหลายภาษาใน request เดียว
### 3) ทำ error ให้ LLM-friendly
validation error ควรบอก:
+2 -1
View File
@@ -351,10 +351,12 @@ export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
export const ProductCarouselPayloadSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
collectionId: z.string().optional(),
limit: z.number().int().positive().optional(),
showPrice: z.boolean().optional(),
showAddToCart: z.boolean().optional(),
products: z.array(z.any()).optional(),
});
export type ProductCarouselPayload = z.infer<typeof ProductCarouselPayloadSchema>;
@@ -441,4 +443,3 @@ export const PAGE_SECTION_TYPES = [
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];