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

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
# HomAroma Admin Section Guide
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
## Field Shape Legend
- `localized string` = plain text or `{ th, en, ja, zh, vi, ko }`
- `plain string` = one string only
- `optional` = can be omitted
## Available Blocks
### `HERO`
- Purpose: Main hero banner for a page
- Contains:
- `tagline` localized string
- `title` localized string
- `description` localized string
- `imageUrl` plain string
- `buttonText` localized string
- `buttonLink` plain string
- Best for: Landing pages, featured campaigns, page introductions
### `BANNER`
- Purpose: Image banner with optional CTA
- Contains:
- `imageUrl` plain string
- `linkUrl` plain string
- `title` localized string optional
- `description` localized string optional
- `buttonText` localized string optional
### `TEXT`
- Purpose: Simple content block
- Contains:
- `title` localized string optional
- `content` localized string
- `textAlign` plain string optional: `left`, `center`, `right`
### `ANNOUNCEMENT_BAR`
- Purpose: Rotating announcement strip
- Contains:
- `slides` array of localized strings
- `backgroundColor` plain string optional
- `textColor` plain string optional
- `speed` number optional
### `NEWSLETTER`
- Purpose: Newsletter signup panel
- Contains:
- `title` localized string
- `description` localized string
- `buttonText` localized string
- `placeholderText` plain string optional
- `showPrivacyCheckbox` boolean
- `privacyText` plain string optional
### `EDITORIAL_TILE`
- Purpose: Side-by-side editorial story tile
- Contains:
- `title` localized string
- `description` localized string
- `imageUrl` plain string
- `imagePosition` plain string: `left` or `right`
- `buttonText` plain string optional
- `buttonLink` plain string optional
- `backgroundColor` plain string optional
- `textColor` plain string optional
### `EDITORIAL_CAROUSEL`
- Purpose: Multi-slide editorial storytelling block
- Contains:
- `title` localized string
- `slides` array of:
- `category` localized string
- `title` localized string
- `description` localized string
- `imageUrl` plain string
- `buttonText` plain string optional
- `buttonLink` plain string optional
### `PRODUCTS`
- Purpose: Product list/grid section
- Contains:
- `title` localized string
- `subtitle` localized string optional
- `type` plain string: `featured`, `scent`, or `custom`
- `scentId` number optional
- `productIds` number array optional
- `limit` number optional
- `layout` plain string optional: `grid` or `carousel`
- `enableQuickShop` boolean optional
### `CATEGORIES`
- Purpose: Category showcase block
- Contains:
- `title` localized string
- `subtitle` localized string optional
- `categoryIds` number array optional
### `HERO_ARTICLE`
- Purpose: Editorial hero/article header
- Contains:
- `title` localized string
- `subtitle` localized string optional
- `author` localized string optional
- `category` localized string optional
- `publishDate` plain string optional
- `date` plain string optional
- `imageUrl` plain string
- `backgroundImageUrl` plain string optional
- `overlayColor` plain string optional
- Notes:
- The image is set by entering an image URL in `imageUrl`
- In the Admin UI, the image picker stores the URL for you
### `RICH_TEXT`
- Purpose: Flexible HTML/text content block
- Contains:
- `title` localized string optional
- `content` localized string required
- Notes:
- HTML is allowed
- Use language tabs to translate each version
### `SCENT_NOTES_BREAKDOWN`
- Purpose: Fragrance layer breakdown
- Contains:
- `title` localized string optional
- `description` localized string optional
- `topNotes` array of strings or localized string objects
- `heartNotes` array of strings or localized string objects
- `middleNotes` array of strings or localized string objects
- `baseNotes` array of strings or localized string objects
- Notes:
- `heartNotes` and `middleNotes` are mirrored together by the backend
### `QUOTE_CALLOUT`
- Purpose: Highlighted quote block
- Contains:
- `quote` localized string required
- `author` localized string optional
- `role` localized string optional
### `PRODUCT_CAROUSEL`
- Purpose: Featured product carousel
- Contains:
- `title` localized string optional
- `description` localized string optional
- `collectionId` plain string optional
- `limit` number optional
- `showPrice` boolean optional
- `showAddToCart` boolean optional
- `products` optional explicit product list
- Notes:
- If `products` is omitted, the backend can auto-populate products on read-back
### `ACCORDION`
- Purpose: FAQ-style expandable block
- Contains:
- `title` localized string optional
- `categories` required array
- each category has:
- `name` localized string
- `icon` plain string optional
- `questions` required array
- each question has:
- `q` localized string
- `a` localized string
### `CONTACT_INFO`
- Purpose: Contact details block
- Contains:
- `title` localized string optional
- `address` localized string required
- `email` plain string required
- `line` plain string required
- `hours` localized string required
### `CONTACT_FORM`
- Purpose: Contact form header block
- Contains:
- `title` localized string optional
- `subtitle` localized string optional
- Notes:
- This block has no extra fields beyond title and subtitle
## Recommended Admin Workflow
1. Create or open a page.
2. Add blocks in the order you want them rendered.
3. Fill localized fields per language.
4. Use the media picker or image URL fields for image-based blocks.
5. Save the block, then verify the rendered page.
## Quick Tips
- Use `HERO_ARTICLE` for editorial-style stories.
- Use `RICH_TEXT` for long-form content.
- Use `SCENT_NOTES_BREAKDOWN` for fragrance layer storytelling.
- Use `PRODUCT_CAROUSEL` when you want featured products without manually listing each one.
- Use `ACCORDION` for FAQ content.
- Use `CONTACT_INFO` and `CONTACT_FORM` together for contact pages.
+140
View File
@@ -0,0 +1,140 @@
# HomAroma MCP Guide for LLMs
This guide explains how to use the HomAroma MCP tools safely and consistently.
## Core Rules
- Prefer the dedicated block tools over the generic `add_page_section` / `update_page_section` tools when available.
- Use `update_page_translations` when you need to set several languages for a page title in one call.
- Treat localized fields and plain strings differently.
- Read back the page after edits when you need confirmation.
## Finding a Page
- Use `get_dynamic_page_detail` with the page slug when you already know the slug.
- Use `list_dynamic_pages_metadata` when you need to search all pages quickly.
- The dynamic page detail response includes the page `id`, which is the `pageId` you use for section tools.
## Create Page Response Contract
`create_dynamic_page` now returns a structured response that is easy for LLMs to parse:
- `success`
- `operation`
- `pageId`
- `slug`
- `title`
- `isPublished`
- `diagnostic`
- `page`
Use `diagnostic` as the human-readable explanation, not as the only signal of success.
## Section Tool Mapping
- `update_hero_article`
- `update_rich_text`
- `update_scent_notes_breakdown`
- `update_quote_callout`
- `update_product_carousel`
- `update_accordion`
- `update_contact_info`
- `update_contact_form`
These tools return a normalized response with:
- `success`
- `tool`
- `sectionType`
- `sectionId`
- `updatedFields`
- `appliedPayload`
- `notes`
- `section`
## Payload Shape Cheat Sheet
### Localized string
Use this when a field can be translated:
```json
{ "th": "ข้อความภาษาไทย", "en": "English text" }
```
### Plain string
Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
## Block-Specific Rules
### `HERO_ARTICLE`
- Localized: `title`, `subtitle`, `author`, `category`
- Plain string: `publishDate`, `date`, `imageUrl`, `backgroundImageUrl`, `overlayColor`
- The image is passed as a URL string in `imageUrl`
### `RICH_TEXT`
- `title` is optional and localized
- `content` is required and localized
- HTML is allowed in `content`
### `SCENT_NOTES_BREAKDOWN`
- `title` and `description` are localized
- Each note item may be a plain string or a localized string object
- `heartNotes` and `middleNotes` are mirrored by the backend
### `QUOTE_CALLOUT`
- `quote` is required and localized
- `author` and `role` are optional localized fields
### `PRODUCT_CAROUSEL`
- `title` and `description` are localized
- `collectionId`, `limit`, `showPrice`, `showAddToCart`, and `products` are plain values
- If `products` is omitted, the backend can auto-populate products on read-back
### `ACCORDION`
- `title` is optional and localized
- `categories` is required
- Each category needs:
- localized `name`
- optional `icon`
- at least one question
- Each question needs localized `q` and `a`
### `CONTACT_INFO`
- `title` is optional and localized
- `address` and `hours` are localized
- `email` and `line` are plain strings
### `CONTACT_FORM`
- `title` and `subtitle` are localized
- There are no extra fields
## Recommended Flow
1. Find the page id from slug or metadata.
2. Create or update the page title translations.
3. Add sections using the dedicated block tool when possible.
4. Read back the page with `get_dynamic_page_detail`.
5. If a section has auto-populated fields, trust the read-back result over the raw update payload.
## What to Expect
- `PRODUCT_CAROUSEL` may return products even when you did not send them.
- `SCENT_NOTES_BREAKDOWN` may show mirrored note groups in the response.
- `create_dynamic_page` should now return a clear success/error contract.
## Safe Usage Tips
- Do not guess the field shape from the UI alone.
- If a field is a URL, send a URL string.
- If a field is translated, send a localized object.
- If you are unsure, inspect an existing page with `get_dynamic_page_detail` first.
+36 -10
View File
@@ -169,12 +169,21 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}) })
.use(adminOnly) .use(adminOnly)
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try {
const data = body; const data = body;
const operation = data?.id ? "update" : "create";
try {
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({
+97 -9
View File
@@ -248,6 +248,66 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details }; 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 }) => {
try {
const data = body as any; const data = body as any;
const operation = data?.id ? "update" : "create";
try {
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
View File
@@ -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 />
+13 -6
View File
@@ -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')}`}
+11 -10
View File
@@ -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
+23 -10
View File
@@ -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
} }
+1 -4
View File
@@ -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
+8
View File
@@ -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 ควรบอก:
+2 -1
View File
@@ -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];