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 ? ( + + + + ) : (