diff --git a/.agents/skills/cms-content-manager/SKILL.md b/.agents/skills/cms-content-manager/SKILL.md
index e270bdf..f196a7f 100644
--- a/.agents/skills/cms-content-manager/SKILL.md
+++ b/.agents/skills/cms-content-manager/SKILL.md
@@ -19,6 +19,10 @@ description: CMS Content Manager / MCP Tester Agent. รับผิดชอบ
- **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP
- **[ต้องทำ]** เมื่อเจอปัญหา Error จาก API (เช่น Validation Error) ให้หยุดทำงานแล้วรายงาน Error กลับไปที่ Main Agent หรือ User ทันที ห้ามคิดหาวิธีแก้โค้ดเอง
+## กฎการจัดการ Product Variants (Variant Mapping Rules)
+- เมื่อใช้งาน Tool ที่เกี่ยวข้องกับการสร้างหรืออัปเดต Variant (เช่น `sync_product_variants`, `create_product`, `bulk_create_products`) **ห้าม**ใช้คำแปลภาษาอื่นในการจับคู่ `option1`, `option2`, หรือ `option3`
+- คุณ **ต้อง** ใช้ค่า Value จากภาษาหลัก (`th`) ที่ระบุไว้ใน `options.values` เสมอ เพื่อให้ Backend สามารถจับคู่ Variant กับ Option ได้ถูกต้อง
+
## เครื่องมือที่แนะนำ (Tools)
- ใช้ `call_mcp_tool` ร่วมกับเซิร์ฟเวอร์ `homaroma_mcp` ในการจัดการข้อมูล
- `create_dynamic_page`: ใช้สร้างหน้าใหม่
diff --git a/AGENTS.md b/AGENTS.md
index ce320a8..0a72aa3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,3 +37,8 @@ Every final handoff should summarize:
- what files or areas were affected
- remaining risks or blockers
- next owner or decision needed
+
+## MCP Blackbox Testing Protocol
+- When instructed to test, use, or audit MCP tools, you MUST operate in **Strict Blackbox Mode**.
+- **FORBIDDEN:** You are strictly prohibited from using `view_file`, `grep_search`, `run_command`, or reading any backend source code (e.g., `schema.prisma`, `types`) to find answers or discover capabilities.
+- **ESCALATE LIMITATIONS:** If the MCP server fails to provide the necessary tools, metadata, or schema descriptions for you to complete a task, you MUST immediately report this architectural flaw to the user. Do not use local file system tools as a silent workaround.
diff --git a/apps/backend/public/uploads/205ea5bf-8497-4f7e-9768-bcd552e40ff8.jpeg b/apps/backend/public/uploads/205ea5bf-8497-4f7e-9768-bcd552e40ff8.jpeg
new file mode 100644
index 0000000..2575b85
Binary files /dev/null and b/apps/backend/public/uploads/205ea5bf-8497-4f7e-9768-bcd552e40ff8.jpeg differ
diff --git a/apps/backend/public/uploads/538886a2-cb9d-4519-ba2d-8a41084d1cca.jpeg b/apps/backend/public/uploads/538886a2-cb9d-4519-ba2d-8a41084d1cca.jpeg
new file mode 100644
index 0000000..3a482ab
Binary files /dev/null and b/apps/backend/public/uploads/538886a2-cb9d-4519-ba2d-8a41084d1cca.jpeg differ
diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts
index c0f5652..a3c6ecb 100644
--- a/apps/mcp-server/index.ts
+++ b/apps/mcp-server/index.ts
@@ -302,7 +302,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
variants: {
type: "array",
- description: "List of variants. 'option1' maps to options[0].values, 'option2' to options[1].values, etc.",
+ description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
@@ -310,9 +310,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
barcode: { type: "string" },
price: { type: "number" },
stock: { type: "number", default: 0 },
- option1: { type: "string" },
- option2: { type: "string" },
- option3: { type: "string" }
+ option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
+ option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
+ option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
@@ -372,6 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
variants: {
type: "array",
+ description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
@@ -379,9 +380,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
barcode: { type: "string" },
price: { type: "number" },
stock: { type: "number", default: 0 },
- option1: { type: "string" },
- option2: { type: "string" },
- option3: { type: "string" }
+ option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
+ option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
+ option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
@@ -408,10 +409,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
properties: {
name: {
type: "object",
+ description: "Object with language codes as keys (e.g. {'th': 'ขนาด', 'en': 'Size'})",
additionalProperties: { type: "string" }
},
values: {
type: "array",
+ description: "Array of language map objects (e.g. [{'th': 'เล็ก', 'en': 'Small'}, {'th': 'ใหญ่', 'en': 'Large'}])",
items: {
type: "object",
additionalProperties: { type: "string" }
@@ -423,6 +426,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
variants: {
type: "array",
+ description: "List of variants mapping to the options. IMPORTANT: option1, option2, option3 MUST perfectly match the primary 'th' value provided in options.values.",
items: {
type: "object",
properties: {
@@ -430,9 +434,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
sku: { type: "string" },
price: { type: "number" },
stock: { type: "number" },
- option1: { type: "string" },
- option2: { type: "string" },
- option3: { type: "string" }
+ option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values (e.g. 'เล็ก'). Do NOT use translated strings." },
+ option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
+ option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
},
required: ["option1"]
}
@@ -465,7 +469,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated product name" },
description: { type: "string", description: "Translated description" },
- details: { type: "string", description: "Translated details (HTML or Markdown allowed)" }
+ details: { type: "string", description: "Translated details (HTML or Markdown allowed)" },
+ options: {
+ type: "array",
+ description: "Translated options mapped by index. Provide array of { name: 'Translated Option', values: ['Trans Val 1', 'Trans Val 2'] }. Do not use for 'th', use sync_product_variants to edit base Thai options.",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ values: { type: "array", items: { type: "string" } }
+ },
+ required: ["name", "values"]
+ }
+ }
},
required: ["id", "lang"]
}
@@ -842,6 +858,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (params.description) payload.description = { [params.lang]: params.description };
if (params.details) payload.details = { [params.lang]: params.details };
+ if (params.options && params.lang !== "th") {
+ const productRes = await api.get(`/products/${params.id}`);
+ const product = productRes.data;
+ if (product && Array.isArray(product.options)) {
+ const mergedOptions = product.options.map((opt: any, i: number) => {
+ const newOpt = params.options[i];
+ if (!newOpt) return opt;
+
+ const mergedName = { ...opt.name, [params.lang]: newOpt.name };
+ const mergedValues = Array.isArray(opt.values) ? opt.values.map((val: any, j: number) => {
+ const newVal = newOpt.values?.[j];
+ return newVal ? { ...val, [params.lang]: newVal } : val;
+ }) : opt.values;
+
+ return { ...opt, name: mergedName, values: mergedValues };
+ });
+ payload.options = mergedOptions;
+ }
+ }
+
await api.patch(`/products/${params.id}`, payload);
return {
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
diff --git a/apps/spa/src/components/ProductCard.tsx b/apps/spa/src/components/ProductCard.tsx
index dd7e4b2..5740773 100644
--- a/apps/spa/src/components/ProductCard.tsx
+++ b/apps/spa/src/components/ProductCard.tsx
@@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import type { Product, TranslatableString } from '../types';
import { getCategoryImage, formatPrice } from '../types';
-import { Heart, ShoppingBag } from 'lucide-react';
+import { Heart, ShoppingBag, Layers } from 'lucide-react';
import { useWishlistStore } from '../store/wishlistStore';
import { useCartStore } from '../store/cartStore';
import { useTranslation } from 'react-i18next';
@@ -99,36 +99,19 @@ export default function ProductCard({ product, enableQuickShop = false, category
- {enableQuickShop && product.variants && product.variants.length > 0 && (
-
- {product.variants.map((variant) => {
- const isSelected = selectedVariant?.id === variant.id;
- return (
-
- );
- })}
+ {enableQuickShop && product.variants && product.variants.length > 1 && (
+
+
+
+ {product.variants.length} {t('product.variantsAvailable', 'Options')}
+
)}
- {formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
+ {formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? product.price)}
{!enableQuickShop && (
@@ -137,26 +120,31 @@ export default function ProductCard({ product, enableQuickShop = false, category
)}
- {enableQuickShop && selectedVariant && (
+ {enableQuickShop && (
)}
diff --git a/apps/spa/src/locales/en/translation.json b/apps/spa/src/locales/en/translation.json
index 780fb04..5346300 100644
--- a/apps/spa/src/locales/en/translation.json
+++ b/apps/spa/src/locales/en/translation.json
@@ -61,7 +61,9 @@
"relatedProducts": "Related Products",
"moreRelated": "More {{category}}",
"reviews": "Reviews",
- "viewDetails": "View Details →"
+ "viewDetails": "View Details →",
+ "variantsAvailable": "Options",
+ "selectOptions": "Select Options"
},
"checkout": {
"title": "Checkout",
diff --git a/apps/spa/src/locales/ja/translation.json b/apps/spa/src/locales/ja/translation.json
index 87b9d01..b3ea54c 100644
--- a/apps/spa/src/locales/ja/translation.json
+++ b/apps/spa/src/locales/ja/translation.json
@@ -58,6 +58,8 @@
"relatedProducts": "関連商品",
"moreRelated": "関連する {{category}}",
"reviews": "レビュー",
- "viewDetails": "詳細を見る →"
+ "viewDetails": "詳細を見る →",
+ "variantsAvailable": "オプション",
+ "selectOptions": "オプションを選択"
}
}
diff --git a/apps/spa/src/locales/ko/translation.json b/apps/spa/src/locales/ko/translation.json
index 64a803e..71f9c24 100644
--- a/apps/spa/src/locales/ko/translation.json
+++ b/apps/spa/src/locales/ko/translation.json
@@ -58,6 +58,8 @@
"relatedProducts": "관련 상품",
"moreRelated": "더 많은 {{category}}",
"reviews": "리뷰",
- "viewDetails": "상세 보기 →"
+ "viewDetails": "상세 보기 →",
+ "variantsAvailable": "옵션",
+ "selectOptions": "옵션 선택"
}
}
diff --git a/apps/spa/src/locales/th/translation.json b/apps/spa/src/locales/th/translation.json
index d887ac0..452527b 100644
--- a/apps/spa/src/locales/th/translation.json
+++ b/apps/spa/src/locales/th/translation.json
@@ -61,7 +61,9 @@
"relatedProducts": "สินค้าที่เกี่ยวข้อง",
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
"reviews": "รีวิว",
- "viewDetails": "ดูรายละเอียด →"
+ "viewDetails": "ดูรายละเอียด →",
+ "variantsAvailable": "ตัวเลือก",
+ "selectOptions": "เลือกตัวเลือก"
},
"checkout": {
"title": "ชำระเงิน",
diff --git a/apps/spa/src/locales/vi/translation.json b/apps/spa/src/locales/vi/translation.json
index 0c2bbb7..9df5b2a 100644
--- a/apps/spa/src/locales/vi/translation.json
+++ b/apps/spa/src/locales/vi/translation.json
@@ -58,6 +58,8 @@
"relatedProducts": "Sản phẩm liên quan",
"moreRelated": "Thêm {{category}}",
"reviews": "Đánh giá",
- "viewDetails": "Xem chi tiết →"
+ "viewDetails": "Xem chi tiết →",
+ "variantsAvailable": "Tùy chọn",
+ "selectOptions": "Chọn tùy chọn"
}
}
diff --git a/apps/spa/src/locales/zh/translation.json b/apps/spa/src/locales/zh/translation.json
index 4848599..e310309 100644
--- a/apps/spa/src/locales/zh/translation.json
+++ b/apps/spa/src/locales/zh/translation.json
@@ -58,6 +58,8 @@
"relatedProducts": "相关商品",
"moreRelated": "更多 {{category}}",
"reviews": "评价",
- "viewDetails": "查看详情 →"
+ "viewDetails": "查看详情 →",
+ "variantsAvailable": "选项",
+ "selectOptions": "选择选项"
}
}
diff --git a/apps/website/src/app/globals.css b/apps/website/src/app/globals.css
index 329f63f..8603d54 100644
--- a/apps/website/src/app/globals.css
+++ b/apps/website/src/app/globals.css
@@ -10,7 +10,7 @@
--color-brand-soft: #D8BFD8;
--color-surface-page: #FFF0F5;
--color-surface-nav-rest: #F9F8F5;
- --color-surface-card: var(--color-surface-card);
+ --color-surface-card: #FFFFFF;
--color-text-main: #333333;
--color-muted-text: #666666;
--color-line-primary: #00B900;
diff --git a/apps/website/src/components/Navbar.tsx b/apps/website/src/components/Navbar.tsx
index e0d6838..681f880 100644
--- a/apps/website/src/components/Navbar.tsx
+++ b/apps/website/src/components/Navbar.tsx
@@ -39,36 +39,7 @@ function setLanguageCookie(code: string) {
}
}
-function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
- const categoryLinks = categories.map((category) => {
- // category.name is a multilingual Record
when fetched with lang=all
- const label: Record =
- typeof category.name === 'string'
- ? { th: category.name, en: category.name }
- : (category.name as Record)
- return { label, url: `/category/${category.id}` }
- })
- return [
- { id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' },
- {
- id: 'shop',
- label: { th: 'ร้านค้า', en: 'Shop' },
- url: '/shop',
- columns: categoryLinks.length
- ? [
- {
- title: { th: 'คอลเลกชัน', en: 'Collections' },
- links: categoryLinks,
- },
- ]
- : undefined,
- },
- { id: 'promotions', label: { th: 'โปรโมชัน', en: 'Promotions' }, url: '/coupons' },
- { id: 'scent-guide', label: { th: 'คลาสแนะนำกลิ่น', en: 'Scent Guide' }, url: '/scent-guide' },
- { id: 'about', label: { th: 'เกี่ยวกับเรา', en: 'About' }, url: '/about' },
- ]
-}
export default function Navbar({ categories = [], initialMenu = [], settings = null }: NavbarProps) {
const [searchOpen, setSearchOpen] = useState(false)
@@ -90,9 +61,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
const { t, i18n } = useTranslation()
const lang = useLang()
- const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
- // Use server menu if available, fall back to categories-based menu
- const menuItems = megaMenu.length > 0 ? megaMenu : fallbackMenu
+ const menuItems = megaMenu
const changeLanguage = (code: string) => {
i18n.changeLanguage(code)
diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx
index ce8f6eb..ca8e051 100644
--- a/apps/website/src/components/ProductCard.tsx
+++ b/apps/website/src/components/ProductCard.tsx
@@ -3,7 +3,7 @@
import { useState } from 'react'
import Image from 'next/image'
import Link from './LocalizedLink'
-import { ShoppingBag } from 'lucide-react'
+import { ShoppingBag, Layers } from 'lucide-react'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
@@ -33,7 +33,26 @@ export default function ProductCard({ product }: ProductCardProps) {
vi: 'Thêm vào giỏ',
ko: '장바구니에 담기'
}
+ const optionsLabels: Record = {
+ th: 'ตัวเลือก',
+ en: 'Options',
+ ja: 'オプション',
+ zh: '选项',
+ vi: 'Tùy chọn',
+ ko: '옵션'
+ }
+ const selectOptionsLabels: Record = {
+ th: 'เลือกตัวเลือก',
+ en: 'Select Options',
+ ja: 'オプションを選択',
+ zh: '选择选项',
+ vi: 'Chọn tùy chọn',
+ ko: '옵션 선택'
+ }
+
const addToCartText = addToCartLabels[lang] || addToCartLabels.en
+ const optionsText = optionsLabels[lang] || optionsLabels.en
+ const selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
return (
@@ -69,56 +88,51 @@ export default function ProductCard({ product }: ProductCardProps) {
)}
- {product.variants.length > 0 && (
-
- {product.variants.map((variant) => {
- const isSelected = selectedVariant?.id === variant.id
- return (
-
- )
- })}
+ {product.variants && product.variants.length > 1 && (
+
+
+
+ {product.variants.length} {optionsText}
+
)}
- {formatPrice(displayPrice)}
+ {formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? product.price)}
- {selectedVariant && (
+ {product.variants && product.variants.length > 1 ? (
+
+
+
+ ) : (