x
This commit is contained in:
1 parent
6f67ee6077
commit
62db477dbd
13 files changed
+659
-97
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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')}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ควรบอก:
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user