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)
|
.use(adminOnly)
|
||||||
.post("/", async ({ body, set }) => {
|
.post("/", async ({ body, set }) => {
|
||||||
|
const data = body;
|
||||||
|
const operation = data?.id ? "update" : "create";
|
||||||
try {
|
try {
|
||||||
const data = body;
|
|
||||||
const { id, slug, title, isPublished, status, metadata } = data;
|
const { id, slug, title, isPublished, status, metadata } = data;
|
||||||
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
|
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
|
||||||
set.status = 400;
|
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;
|
let finalIsPublished = true;
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
@@ -183,22 +192,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
else if (isPublished !== undefined) {
|
else if (isPublished !== undefined) {
|
||||||
finalIsPublished = isPublished;
|
finalIsPublished = isPublished;
|
||||||
}
|
}
|
||||||
if (id) {
|
const page = id
|
||||||
return await prisma.page.update({
|
? await prisma.page.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: { slug, title, isPublished: finalIsPublished }
|
data: { slug, title, isPublished: finalIsPublished }
|
||||||
});
|
})
|
||||||
}
|
: await prisma.page.create({
|
||||||
else {
|
|
||||||
return await prisma.page.create({
|
|
||||||
data: { slug, title, isPublished: finalIsPublished }
|
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) {
|
catch (err) {
|
||||||
console.error("[Pages] Error:", err);
|
console.error("[Pages] Error:", err);
|
||||||
set.status = 500;
|
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({
|
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 };
|
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') {
|
} else if (section.type === 'CATEGORIES') {
|
||||||
let categories: any[] = [];
|
let categories: any[] = [];
|
||||||
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||||
@@ -328,13 +388,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
})
|
})
|
||||||
.use(adminOnly)
|
.use(adminOnly)
|
||||||
.post("/", async ({ body, set }) => {
|
.post("/", async ({ body, set }) => {
|
||||||
|
const data = body as any;
|
||||||
|
const operation = data?.id ? "update" : "create";
|
||||||
try {
|
try {
|
||||||
const data = body as any;
|
|
||||||
const { id, slug, title, isPublished, status, metadata } = data;
|
const { id, slug, title, isPublished, status, metadata } = data;
|
||||||
|
|
||||||
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
|
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
|
||||||
set.status = 400;
|
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;
|
let finalIsPublished = true;
|
||||||
@@ -344,20 +413,39 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
finalIsPublished = isPublished;
|
finalIsPublished = isPublished;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
const page = id
|
||||||
return await prisma.page.update({
|
? await prisma.page.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: { slug, title, isPublished: finalIsPublished }
|
data: { slug, title, isPublished: finalIsPublished }
|
||||||
});
|
})
|
||||||
} else {
|
: await prisma.page.create({
|
||||||
return await prisma.page.create({
|
|
||||||
data: { slug, title, isPublished: finalIsPublished }
|
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) {
|
} catch (err) {
|
||||||
console.error("[Pages] Error:", err);
|
console.error("[Pages] Error:", err);
|
||||||
set.status = 500;
|
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({
|
body: t.Object({
|
||||||
|
|||||||
+97
-44
@@ -55,92 +55,96 @@ const localizedStringListSchema = {
|
|||||||
items: localizedStringSchema
|
items: localizedStringSchema
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const localizedStringExample = '{ "th": "ข้อความภาษาไทย", "en": "English text" }';
|
||||||
|
const localizedStringListExample = `[ ${localizedStringExample} ]`;
|
||||||
|
|
||||||
const SECTION_TOOL_CONFIGS = [
|
const SECTION_TOOL_CONFIGS = [
|
||||||
{
|
{
|
||||||
name: "update_hero_article",
|
name: "update_hero_article",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: "Localized article title" },
|
title: { ...localizedStringSchema, description: `Localized article title, for example ${localizedStringExample}` },
|
||||||
subtitle: { ...localizedStringSchema, description: "Localized subtitle" },
|
subtitle: { ...localizedStringSchema, description: `Localized subtitle, for example ${localizedStringExample}` },
|
||||||
author: { ...localizedStringSchema, description: "Localized author name" },
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
||||||
category: { ...localizedStringSchema, description: "Localized category or tag" },
|
category: { ...localizedStringSchema, description: `Localized category or tag, for example ${localizedStringExample}` },
|
||||||
publishDate: { type: "string", description: "Publish date or display date" },
|
publishDate: { type: "string", description: "Plain string publish date or display date" },
|
||||||
date: { type: "string", description: "Alternative date field accepted by backend" },
|
date: { type: "string", description: "Plain string alternative date field accepted by backend" },
|
||||||
imageUrl: { type: "string", description: "Main image URL" },
|
imageUrl: { type: "string", description: "Plain string main image URL" },
|
||||||
backgroundImageUrl: { type: "string", description: "Background image URL" },
|
backgroundImageUrl: { type: "string", description: "Plain string background image URL" },
|
||||||
overlayColor: { type: "string", description: "Overlay color in hex or rgba" }
|
overlayColor: { type: "string", description: "Plain string overlay color in hex or rgba" }
|
||||||
},
|
},
|
||||||
required: ["sectionId"]
|
required: ["sectionId"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_rich_text",
|
name: "update_rich_text",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: "Localized title displayed above the content" },
|
title: { ...localizedStringSchema, description: `Localized title displayed above the content, for example ${localizedStringExample}` },
|
||||||
content: { ...localizedStringSchema, description: "Localized HTML or plain text content" }
|
content: { ...localizedStringSchema, description: `Localized HTML or plain text content, for example ${localizedStringExample}` }
|
||||||
},
|
},
|
||||||
required: ["sectionId", "content"]
|
required: ["sectionId", "content"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_scent_notes_breakdown",
|
name: "update_scent_notes_breakdown",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: "Localized title" },
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
||||||
description: { ...localizedStringSchema, description: "Localized supporting description" },
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
||||||
topNotes: { type: "array", items: localizedStringSchema, description: "Top note values" },
|
topNotes: { ...localizedStringListSchema, description: `Top note values, for example ${localizedStringListExample}` },
|
||||||
heartNotes: { type: "array", items: localizedStringSchema, description: "Heart note values" },
|
heartNotes: { ...localizedStringListSchema, description: `Heart note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
||||||
middleNotes: { type: "array", items: localizedStringSchema, description: "Middle note values" },
|
middleNotes: { ...localizedStringListSchema, description: `Middle note values. The server mirrors heartNotes and middleNotes, for example ${localizedStringListExample}` },
|
||||||
baseNotes: { type: "array", items: localizedStringSchema, description: "Base note values" }
|
baseNotes: { ...localizedStringListSchema, description: `Base note values, for example ${localizedStringListExample}` }
|
||||||
},
|
},
|
||||||
required: ["sectionId"]
|
required: ["sectionId"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_quote_callout",
|
name: "update_quote_callout",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
quote: { ...localizedStringSchema, description: "Localized quote text" },
|
quote: { ...localizedStringSchema, description: `Localized quote text, for example ${localizedStringExample}` },
|
||||||
author: { ...localizedStringSchema, description: "Localized author name" },
|
author: { ...localizedStringSchema, description: `Localized author name, for example ${localizedStringExample}` },
|
||||||
role: { ...localizedStringSchema, description: "Localized author role or title" }
|
role: { ...localizedStringSchema, description: `Localized author role or title, for example ${localizedStringExample}` }
|
||||||
},
|
},
|
||||||
required: ["sectionId"]
|
required: ["sectionId"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_product_carousel",
|
name: "update_product_carousel",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: "Localized title" },
|
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
|
||||||
description: { ...localizedStringSchema, description: "Localized supporting description" },
|
description: { ...localizedStringSchema, description: `Localized supporting description, for example ${localizedStringExample}` },
|
||||||
collectionId: { type: "string", description: "Optional collection identifier" },
|
collectionId: { type: "string", description: "Optional plain string collection identifier" },
|
||||||
limit: { type: "number", description: "Maximum number of products to show" },
|
limit: { type: "number", description: "Maximum number of products to show" },
|
||||||
showPrice: { type: "boolean", description: "Show product price" },
|
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"]
|
required: ["sectionId"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_accordion",
|
name: "update_accordion",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
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: {
|
categories: {
|
||||||
type: "array",
|
type: "array",
|
||||||
description: "Accordion categories",
|
description: "Accordion categories",
|
||||||
items: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
name: { ...localizedStringSchema, description: "Localized category name" },
|
name: { ...localizedStringSchema, description: `Localized category name, for example ${localizedStringExample}` },
|
||||||
icon: { type: "string", description: "Optional icon or emoji" },
|
icon: { type: "string", description: "Optional icon or emoji" },
|
||||||
questions: {
|
questions: {
|
||||||
type: "array",
|
type: "array",
|
||||||
@@ -148,8 +152,8 @@ const SECTION_TOOL_CONFIGS = [
|
|||||||
items: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
q: { ...localizedStringSchema, description: "Localized question" },
|
q: { ...localizedStringSchema, description: `Localized question, for example ${localizedStringExample}` },
|
||||||
a: { ...localizedStringSchema, description: "Localized answer" }
|
a: { ...localizedStringSchema, description: `Localized answer, for example ${localizedStringExample}` }
|
||||||
},
|
},
|
||||||
required: ["q", "a"]
|
required: ["q", "a"]
|
||||||
}
|
}
|
||||||
@@ -164,25 +168,25 @@ const SECTION_TOOL_CONFIGS = [
|
|||||||
{
|
{
|
||||||
name: "update_contact_info",
|
name: "update_contact_info",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
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}` },
|
||||||
address: { ...localizedStringSchema, description: "Localized address" },
|
address: { ...localizedStringSchema, description: `Localized address, for example ${localizedStringExample}` },
|
||||||
email: { ...localizedStringSchema, description: "Localized email" },
|
email: { type: "string", description: "Plain string email address" },
|
||||||
line: { ...localizedStringSchema, description: "Localized Line ID or contact" },
|
line: { type: "string", description: "Plain string Line ID or contact" },
|
||||||
hours: { ...localizedStringSchema, description: "Localized business hours" }
|
hours: { ...localizedStringSchema, description: `Localized business hours, for example ${localizedStringExample}` }
|
||||||
},
|
},
|
||||||
required: ["sectionId", "address", "email", "line", "hours"]
|
required: ["sectionId", "address", "email", "line", "hours"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_contact_form",
|
name: "update_contact_form",
|
||||||
sectionType: "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: {
|
properties: {
|
||||||
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
title: { ...localizedStringSchema, description: "Localized form title" },
|
title: { ...localizedStringSchema, description: `Localized form title, for example ${localizedStringExample}` },
|
||||||
subtitle: { ...localizedStringSchema, description: "Localized form subtitle" }
|
subtitle: { ...localizedStringSchema, description: `Localized form subtitle, for example ${localizedStringExample}` }
|
||||||
},
|
},
|
||||||
required: ["sectionId"]
|
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>) {
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -214,6 +225,8 @@ function buildSectionUpdateResult(toolName: string, sectionType: string, updated
|
|||||||
sectionType,
|
sectionType,
|
||||||
sectionId: updatedSection?.id,
|
sectionId: updatedSection?.id,
|
||||||
updatedFields: Object.keys(payload),
|
updatedFields: Object.keys(payload),
|
||||||
|
appliedPayload: payload,
|
||||||
|
notes,
|
||||||
section: updatedSection
|
section: updatedSection
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -404,6 +417,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["id", "lang", "title"]
|
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",
|
name: "update_hero_translation",
|
||||||
description: "Add or update translations for a specific hero.",
|
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) }] };
|
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": {
|
case "update_hero_translation": {
|
||||||
const { id, lang, tagline, title, description, buttonText } = params as any;
|
const { id, lang, tagline, title, description, buttonText } = params as any;
|
||||||
const payload: any = {};
|
const payload: any = {};
|
||||||
@@ -984,7 +1019,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
payload.id = params.id;
|
payload.id = params.id;
|
||||||
}
|
}
|
||||||
const res = await api.post("/pages", payload);
|
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": {
|
case "add_page_section": {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
@@ -1019,6 +1065,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
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}`, {
|
const res = await api.put(`/pages/sections/${params.sectionId}`, {
|
||||||
type: config.sectionType,
|
type: config.sectionType,
|
||||||
...payload
|
...payload
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
import '../lib/i18n';
|
import '../lib/i18n';
|
||||||
import CartDrawer from './CartDrawer';
|
import CartDrawer from './CartDrawer';
|
||||||
import ScrollToTop from './ScrollToTop';
|
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 }) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -20,12 +22,17 @@ export default function Footer({
|
|||||||
}: FooterProps) {
|
}: FooterProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const lang = useLang()
|
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
|
// Translate a menu item label using its translations array or i18n key map
|
||||||
const translateMenuLabel = (item: MenuItem): string => {
|
const translateMenuLabel = (item: MenuItem): string => {
|
||||||
// Use outer `lang` from closure — hooks cannot be called inside nested functions
|
// Use outer `lang` from closure — hooks cannot be called inside nested functions
|
||||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
if (effectiveLang !== 'en' && item.translations && item.translations.length > 0) {
|
||||||
const trans = item.translations.find((tr: any) => tr.langCode === lang)
|
const trans = item.translations.find((tr: any) => tr.langCode === effectiveLang)
|
||||||
if (trans?.label) return trans.label
|
if (trans?.label) return trans.label
|
||||||
}
|
}
|
||||||
const map: Record<string, string> = {
|
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>
|
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-gray-600">
|
<p className="text-sm leading-relaxed text-gray-600">
|
||||||
{settings?.footerDescription || t('footer.catchphrase')}
|
{resolveLocalized(settings?.footerDescription, effectiveLang) || t('footer.catchphrase')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{hasSocialLinks ? (
|
{hasSocialLinks ? (
|
||||||
@@ -135,7 +142,7 @@ export default function Footer({
|
|||||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<Link key={c.id} href={`/category/${c.id}`} className="transition-colors hover:text-pop-pink">
|
<Link key={c.id} href={`/category/${c.id}`} className="transition-colors hover:text-pop-pink">
|
||||||
{resolveLocalized(c.name)}
|
{resolveLocalized(c.name, effectiveLang)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +154,7 @@ export default function Footer({
|
|||||||
{settings?.footerSupportHeading
|
{settings?.footerSupportHeading
|
||||||
? (typeof settings.footerSupportHeading === 'string'
|
? (typeof settings.footerSupportHeading === 'string'
|
||||||
? settings.footerSupportHeading
|
? settings.footerSupportHeading
|
||||||
: settings.footerSupportHeading[lang]
|
: settings.footerSupportHeading[effectiveLang]
|
||||||
|| settings.footerSupportHeading.en
|
|| settings.footerSupportHeading.en
|
||||||
|| 'Support & Legal')
|
|| 'Support & Legal')
|
||||||
: t('footer.support', 'Support & Legal')}
|
: t('footer.support', 'Support & Legal')}
|
||||||
@@ -165,7 +172,7 @@ export default function Footer({
|
|||||||
{settings?.footerCopyrightText
|
{settings?.footerCopyrightText
|
||||||
? (typeof settings.footerCopyrightText === 'string'
|
? (typeof settings.footerCopyrightText === 'string'
|
||||||
? settings.footerCopyrightText
|
? settings.footerCopyrightText
|
||||||
: settings.footerCopyrightText[lang]
|
: settings.footerCopyrightText[effectiveLang]
|
||||||
|| settings.footerCopyrightText.en
|
|| settings.footerCopyrightText.en
|
||||||
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
|
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
|
||||||
: `© ${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[] {
|
function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
||||||
const categoryLinks = categories.map((category) => ({
|
const categoryLinks = categories.map((category) => {
|
||||||
label: {
|
// category.name is a multilingual Record<lang,string> when fetched with lang=all
|
||||||
th: resolveLocalized(category.name, 'th') || resolveLocalized(category.name),
|
const label: Record<string, string> =
|
||||||
en: resolveLocalized(category.name, 'en') || resolveLocalized(category.name),
|
typeof category.name === 'string'
|
||||||
},
|
? { th: category.name, en: category.name }
|
||||||
url: `/category/${category.id}`,
|
: (category.name as Record<string, string>)
|
||||||
}))
|
return { label, url: `/category/${category.id}` }
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' },
|
{ 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 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 (
|
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="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">
|
<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) => {
|
{LANGUAGES.map((language) => {
|
||||||
const isActive = i18n.language.startsWith(language.code)
|
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={language.code}
|
key={language.code}
|
||||||
@@ -365,7 +366,7 @@ export default function Navbar({ categories = [], initialMenu = [] }: NavbarProp
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{LANGUAGES.map((language) => {
|
{LANGUAGES.map((language) => {
|
||||||
const isActive = i18n.language.startsWith(language.code)
|
const isActive = lang.startsWith(language.code) || language.code.startsWith(lang)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={language.code}
|
key={language.code}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Link from 'next/link'
|
|||||||
import type { Product } from '@homaroma/types'
|
import type { Product } from '@homaroma/types'
|
||||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
import { resolveLocalized } from '../lib/localization'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useLang } from '../hooks/useLang'
|
import { useLang } from '../hooks/useLang'
|
||||||
import WishlistButton from './WishlistButton'
|
import WishlistButton from './WishlistButton'
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ interface ProductCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product }: ProductCardProps) {
|
||||||
const { i18n } = useTranslation()
|
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const categoryName = resolveLocalized(product.category.name, lang)
|
const categoryName = resolveLocalized(product.category.name, lang)
|
||||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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'
|
* WHY this pattern is necessary:
|
||||||
* (matching the SSR output). After hydration it updates to the user's saved
|
* - i18n.ts reads localStorage synchronously at module-load time (before React hydrates)
|
||||||
* language so all components re-render with the correct locale without
|
* - So i18n.language may already be 'ja', 'ko', etc. when client components first render
|
||||||
* triggering a hydration mismatch.
|
* - 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 {
|
export function useLang(): string {
|
||||||
const { i18n } = useTranslation()
|
// Always 'th' on first render (matches SSR) — updated post-hydration
|
||||||
const [lang, setLang] = useState('th') // must match server default
|
const [lang, setLang] = useState('th')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLang(i18n.language ? i18n.language.substring(0, 2) : 'th')
|
// Sync immediately after hydration
|
||||||
}, [i18n.language])
|
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
|
return lang
|
||||||
}
|
}
|
||||||
@@ -8,9 +8,6 @@ import zhTranslation from '../locales/zh/translation.json'
|
|||||||
import viTranslation from '../locales/vi/translation.json'
|
import viTranslation from '../locales/vi/translation.json'
|
||||||
import koTranslation from '../locales/ko/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
|
// Check if it's already initialized to prevent re-initialization on HMR
|
||||||
if (!i18n.isInitialized) {
|
if (!i18n.isInitialized) {
|
||||||
i18n
|
i18n
|
||||||
@@ -24,7 +21,7 @@ if (!i18n.isInitialized) {
|
|||||||
vi: { translation: viTranslation },
|
vi: { translation: viTranslation },
|
||||||
ko: { translation: koTranslation },
|
ko: { translation: koTranslation },
|
||||||
},
|
},
|
||||||
lng: savedLang, // default language
|
lng: 'th',
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // react already safes from xss
|
escapeValue: false, // react already safes from xss
|
||||||
|
|||||||
@@ -196,9 +196,14 @@ Translation records ถูกบันทึกจริงในระบบแ
|
|||||||
- `update_accordion`
|
- `update_accordion`
|
||||||
- `update_contact_info`
|
- `update_contact_info`
|
||||||
- `update_contact_form`
|
- `update_contact_form`
|
||||||
|
- `update_page_translations`
|
||||||
|
|
||||||
ถ้าจะเพิ่มฝั่ง create แบบเฉพาะ block ต่อไป ค่อยขยายเป็นคู่ `add_*` เพิ่มจากชุดนี้ได้อีกชั้น
|
ถ้าจะเพิ่มฝั่ง 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
|
### 2) ทำ structured read-back
|
||||||
|
|
||||||
ตอนนี้ update tools คืนข้อมูลแบบ normalize แล้วใน JSON response
|
ตอนนี้ update tools คืนข้อมูลแบบ normalize แล้วใน JSON response
|
||||||
@@ -209,6 +214,9 @@ Translation records ถูกบันทึกจริงในระบบแ
|
|||||||
- field ไหนถูก overwrite
|
- field ไหนถูก overwrite
|
||||||
- payload สุดท้ายหน้าตาเป็นอะไร
|
- payload สุดท้ายหน้าตาเป็นอะไร
|
||||||
|
|
||||||
|
> ตอนนี้ response มี `appliedPayload` เพิ่มมาแล้ว ทำให้ LLM เห็น payload ที่ส่งจริงได้ทันที
|
||||||
|
> `update_page_translations` ช่วยลดการเรียกซ้ำทีละภาษาได้มาก และคำอธิบายใหม่จะบอกชัดว่าควรใช้เมื่ออยากอัปเดตหลายภาษาใน request เดียว
|
||||||
|
|
||||||
### 3) ทำ error ให้ LLM-friendly
|
### 3) ทำ error ให้ LLM-friendly
|
||||||
|
|
||||||
validation error ควรบอก:
|
validation error ควรบอก:
|
||||||
|
|||||||
@@ -351,10 +351,12 @@ export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
|
|||||||
|
|
||||||
export const ProductCarouselPayloadSchema = z.object({
|
export const ProductCarouselPayloadSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
collectionId: z.string().optional(),
|
collectionId: z.string().optional(),
|
||||||
limit: z.number().int().positive().optional(),
|
limit: z.number().int().positive().optional(),
|
||||||
showPrice: z.boolean().optional(),
|
showPrice: z.boolean().optional(),
|
||||||
showAddToCart: z.boolean().optional(),
|
showAddToCart: z.boolean().optional(),
|
||||||
|
products: z.array(z.any()).optional(),
|
||||||
});
|
});
|
||||||
export type ProductCarouselPayload = z.infer<typeof ProductCarouselPayloadSchema>;
|
export type ProductCarouselPayload = z.infer<typeof ProductCarouselPayloadSchema>;
|
||||||
|
|
||||||
@@ -441,4 +443,3 @@ export const PAGE_SECTION_TYPES = [
|
|||||||
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM'
|
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM'
|
||||||
] as const;
|
] as const;
|
||||||
export type SectionType = typeof PAGE_SECTION_TYPES[number];
|
export type SectionType = typeof PAGE_SECTION_TYPES[number];
|
||||||
|
|
||||||
Reference in new issue
Block a user