x
This commit is contained in:
1 parent
46d504e483
commit
4950661abf
15 files changed
+139
-111
No files matched your search
@@ -19,6 +19,10 @@ description: CMS Content Manager / MCP Tester Agent. รับผิดชอบ
|
|||||||
- **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP
|
- **[อนุญาต]** ให้อ่านไฟล์ได้เท่านั้น (Read-only) เพื่อดึงข้อมูล Payload ไปยิงเข้า MCP
|
||||||
- **[ต้องทำ]** เมื่อเจอปัญหา Error จาก API (เช่น Validation Error) ให้หยุดทำงานแล้วรายงาน Error กลับไปที่ Main Agent หรือ User ทันที ห้ามคิดหาวิธีแก้โค้ดเอง
|
- **[ต้องทำ]** เมื่อเจอปัญหา 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)
|
## เครื่องมือที่แนะนำ (Tools)
|
||||||
- ใช้ `call_mcp_tool` ร่วมกับเซิร์ฟเวอร์ `homaroma_mcp` ในการจัดการข้อมูล
|
- ใช้ `call_mcp_tool` ร่วมกับเซิร์ฟเวอร์ `homaroma_mcp` ในการจัดการข้อมูล
|
||||||
- `create_dynamic_page`: ใช้สร้างหน้าใหม่
|
- `create_dynamic_page`: ใช้สร้างหน้าใหม่
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ Every final handoff should summarize:
|
|||||||
- what files or areas were affected
|
- what files or areas were affected
|
||||||
- remaining risks or blockers
|
- remaining risks or blockers
|
||||||
- next owner or decision needed
|
- 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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 592 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
+47
-11
@@ -302,7 +302,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
type: "array",
|
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: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -310,9 +310,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
barcode: { type: "string" },
|
barcode: { type: "string" },
|
||||||
price: { type: "number" },
|
price: { type: "number" },
|
||||||
stock: { type: "number", default: 0 },
|
stock: { type: "number", default: 0 },
|
||||||
option1: { type: "string" },
|
option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
|
||||||
option2: { type: "string" },
|
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
|
||||||
option3: { type: "string" }
|
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
|
||||||
},
|
},
|
||||||
required: ["option1"]
|
required: ["option1"]
|
||||||
}
|
}
|
||||||
@@ -372,6 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
type: "array",
|
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: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -379,9 +380,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
barcode: { type: "string" },
|
barcode: { type: "string" },
|
||||||
price: { type: "number" },
|
price: { type: "number" },
|
||||||
stock: { type: "number", default: 0 },
|
stock: { type: "number", default: 0 },
|
||||||
option1: { type: "string" },
|
option1: { type: "string", description: "MUST exactly match the 'th' string from options[0].values. Do NOT use translated strings." },
|
||||||
option2: { type: "string" },
|
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
|
||||||
option3: { type: "string" }
|
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
|
||||||
},
|
},
|
||||||
required: ["option1"]
|
required: ["option1"]
|
||||||
}
|
}
|
||||||
@@ -408,10 +409,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
description: "Object with language codes as keys (e.g. {'th': 'ขนาด', 'en': 'Size'})",
|
||||||
additionalProperties: { type: "string" }
|
additionalProperties: { type: "string" }
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
type: "array",
|
type: "array",
|
||||||
|
description: "Array of language map objects (e.g. [{'th': 'เล็ก', 'en': 'Small'}, {'th': 'ใหญ่', 'en': 'Large'}])",
|
||||||
items: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: { type: "string" }
|
additionalProperties: { type: "string" }
|
||||||
@@ -423,6 +426,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
type: "array",
|
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: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -430,9 +434,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
sku: { type: "string" },
|
sku: { type: "string" },
|
||||||
price: { type: "number" },
|
price: { type: "number" },
|
||||||
stock: { type: "number" },
|
stock: { type: "number" },
|
||||||
option1: { 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" },
|
option2: { type: "string", description: "MUST exactly match the 'th' string from options[1].values. Do NOT use translated strings." },
|
||||||
option3: { type: "string" }
|
option3: { type: "string", description: "MUST exactly match the 'th' string from options[2].values. Do NOT use translated strings." }
|
||||||
},
|
},
|
||||||
required: ["option1"]
|
required: ["option1"]
|
||||||
}
|
}
|
||||||
@@ -465,7 +469,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" },
|
lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" },
|
||||||
name: { type: "string", description: "Translated product name" },
|
name: { type: "string", description: "Translated product name" },
|
||||||
description: { type: "string", description: "Translated description" },
|
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"]
|
required: ["id", "lang"]
|
||||||
}
|
}
|
||||||
@@ -842,6 +858,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
if (params.description) payload.description = { [params.lang]: params.description };
|
if (params.description) payload.description = { [params.lang]: params.description };
|
||||||
if (params.details) payload.details = { [params.lang]: params.details };
|
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);
|
await api.patch(`/products/${params.id}`, payload);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
|
content: [{ type: "text", text: JSON.stringify({ success: true, message: `Translation for language '${params.lang}' updated successfully on product ${params.id}.` }, null, 2) }]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Product, TranslatableString } from '../types';
|
import type { Product, TranslatableString } from '../types';
|
||||||
import { getCategoryImage, formatPrice } 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 { useWishlistStore } from '../store/wishlistStore';
|
||||||
import { useCartStore } from '../store/cartStore';
|
import { useCartStore } from '../store/cartStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -99,36 +99,19 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enableQuickShop && product.variants && product.variants.length > 0 && (
|
{enableQuickShop && product.variants && product.variants.length > 1 && (
|
||||||
<div className="mb-1 mt-2 flex flex-wrap gap-1.5">
|
<div className="mb-1 mt-2 flex items-center">
|
||||||
{product.variants.map((variant) => {
|
<span className="inline-flex items-center gap-1 rounded-md bg-pop-black/5 px-2 py-1 text-[10px] font-semibold text-pop-black/70 border border-pop-black/10 shadow-sm transition-colors group-hover:bg-pop-black/10">
|
||||||
const isSelected = selectedVariant?.id === variant.id;
|
<Layers className="h-3 w-3 opacity-70" />
|
||||||
return (
|
{product.variants.length} {t('product.variantsAvailable', 'Options')}
|
||||||
<button
|
</span>
|
||||||
key={variant.id}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedVariant(variant);
|
|
||||||
}}
|
|
||||||
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
|
|
||||||
isSelected
|
|
||||||
? 'border-pop-black bg-pop-black text-pop-bg shadow-sm'
|
|
||||||
: 'border-pop-black/10 bg-transparent text-pop-black/70 hover:border-pop-black/30 hover:bg-pop-black/5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{[variant.option1, variant.option2, variant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-black text-pop-pink md:text-lg">
|
<span className="text-sm font-black text-pop-pink md:text-lg">
|
||||||
{formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
|
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? product.price)}
|
||||||
</span>
|
</span>
|
||||||
{!enableQuickShop && (
|
{!enableQuickShop && (
|
||||||
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-pop-black transition-transform group-hover:translate-x-1 group-hover:text-pop-pink md:text-xs">
|
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-pop-black transition-transform group-hover:translate-x-1 group-hover:text-pop-pink md:text-xs">
|
||||||
@@ -137,26 +120,31 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enableQuickShop && selectedVariant && (
|
{enableQuickShop && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (product.variants && product.variants.length > 1) {
|
||||||
|
navigate(`/products/${product.id}`);
|
||||||
|
} else if (selectedVariant || product.variants?.[0]) {
|
||||||
|
const targetVariant = selectedVariant || product.variants[0];
|
||||||
addToCart({
|
addToCart({
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
variantId: selectedVariant.id,
|
variantId: targetVariant.id,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
price: selectedVariant.price || 0,
|
price: targetVariant.price || product.price || 0,
|
||||||
productName: displayName,
|
productName: displayName,
|
||||||
variantName: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
variantName: [targetVariant.option1, targetVariant.option2, targetVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
||||||
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
|
className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-3.5 h-3.5" />
|
{!(product.variants && product.variants.length > 1) && <ShoppingBag className="w-3.5 h-3.5" />}
|
||||||
{t('cart.addToCart')}
|
{product.variants && product.variants.length > 1 ? t('product.selectOptions', 'Select Options') : t('cart.addToCart')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
"relatedProducts": "Related Products",
|
"relatedProducts": "Related Products",
|
||||||
"moreRelated": "More {{category}}",
|
"moreRelated": "More {{category}}",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
"viewDetails": "View Details →"
|
"viewDetails": "View Details →",
|
||||||
|
"variantsAvailable": "Options",
|
||||||
|
"selectOptions": "Select Options"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"title": "Checkout",
|
"title": "Checkout",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"relatedProducts": "関連商品",
|
"relatedProducts": "関連商品",
|
||||||
"moreRelated": "関連する {{category}}",
|
"moreRelated": "関連する {{category}}",
|
||||||
"reviews": "レビュー",
|
"reviews": "レビュー",
|
||||||
"viewDetails": "詳細を見る →"
|
"viewDetails": "詳細を見る →",
|
||||||
|
"variantsAvailable": "オプション",
|
||||||
|
"selectOptions": "オプションを選択"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
"relatedProducts": "관련 상품",
|
"relatedProducts": "관련 상품",
|
||||||
"moreRelated": "더 많은 {{category}}",
|
"moreRelated": "더 많은 {{category}}",
|
||||||
"reviews": "리뷰",
|
"reviews": "리뷰",
|
||||||
"viewDetails": "상세 보기 →"
|
"viewDetails": "상세 보기 →",
|
||||||
|
"variantsAvailable": "옵션",
|
||||||
|
"selectOptions": "옵션 선택"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,9 @@
|
|||||||
"relatedProducts": "สินค้าที่เกี่ยวข้อง",
|
"relatedProducts": "สินค้าที่เกี่ยวข้อง",
|
||||||
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
|
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
|
||||||
"reviews": "รีวิว",
|
"reviews": "รีวิว",
|
||||||
"viewDetails": "ดูรายละเอียด →"
|
"viewDetails": "ดูรายละเอียด →",
|
||||||
|
"variantsAvailable": "ตัวเลือก",
|
||||||
|
"selectOptions": "เลือกตัวเลือก"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"title": "ชำระเงิน",
|
"title": "ชำระเงิน",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"relatedProducts": "Sản phẩm liên quan",
|
"relatedProducts": "Sản phẩm liên quan",
|
||||||
"moreRelated": "Thêm {{category}}",
|
"moreRelated": "Thêm {{category}}",
|
||||||
"reviews": "Đánh giá",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
"relatedProducts": "相关商品",
|
"relatedProducts": "相关商品",
|
||||||
"moreRelated": "更多 {{category}}",
|
"moreRelated": "更多 {{category}}",
|
||||||
"reviews": "评价",
|
"reviews": "评价",
|
||||||
"viewDetails": "查看详情 →"
|
"viewDetails": "查看详情 →",
|
||||||
|
"variantsAvailable": "选项",
|
||||||
|
"selectOptions": "选择选项"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
--color-brand-soft: #D8BFD8;
|
--color-brand-soft: #D8BFD8;
|
||||||
--color-surface-page: #FFF0F5;
|
--color-surface-page: #FFF0F5;
|
||||||
--color-surface-nav-rest: #F9F8F5;
|
--color-surface-nav-rest: #F9F8F5;
|
||||||
--color-surface-card: var(--color-surface-card);
|
--color-surface-card: #FFFFFF;
|
||||||
--color-text-main: #333333;
|
--color-text-main: #333333;
|
||||||
--color-muted-text: #666666;
|
--color-muted-text: #666666;
|
||||||
--color-line-primary: #00B900;
|
--color-line-primary: #00B900;
|
||||||
|
|||||||
@@ -39,36 +39,7 @@ function setLanguageCookie(code: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
|
||||||
const categoryLinks = categories.map((category) => {
|
|
||||||
// category.name is a multilingual Record<lang,string> when fetched with lang=all
|
|
||||||
const label: Record<string, string> =
|
|
||||||
typeof category.name === 'string'
|
|
||||||
? { th: category.name, en: category.name }
|
|
||||||
: (category.name as Record<string, string>)
|
|
||||||
return { label, url: `/category/${category.id}` }
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ id: 'home', label: { th: 'หน้าหลัก', en: 'Home' }, url: '/' },
|
|
||||||
{
|
|
||||||
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) {
|
export default function Navbar({ categories = [], initialMenu = [], settings = null }: NavbarProps) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
@@ -90,9 +61,7 @@ export default function Navbar({ categories = [], initialMenu = [], settings = n
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
|
||||||
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
|
const menuItems = megaMenu
|
||||||
// Use server menu if available, fall back to categories-based menu
|
|
||||||
const menuItems = megaMenu.length > 0 ? megaMenu : fallbackMenu
|
|
||||||
|
|
||||||
const changeLanguage = (code: string) => {
|
const changeLanguage = (code: string) => {
|
||||||
i18n.changeLanguage(code)
|
i18n.changeLanguage(code)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from './LocalizedLink'
|
import Link from './LocalizedLink'
|
||||||
import { ShoppingBag } from 'lucide-react'
|
import { ShoppingBag, Layers } from 'lucide-react'
|
||||||
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 { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||||
@@ -33,7 +33,26 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
vi: 'Thêm vào giỏ',
|
vi: 'Thêm vào giỏ',
|
||||||
ko: '장바구니에 담기'
|
ko: '장바구니에 담기'
|
||||||
}
|
}
|
||||||
|
const optionsLabels: Record<string, string> = {
|
||||||
|
th: 'ตัวเลือก',
|
||||||
|
en: 'Options',
|
||||||
|
ja: 'オプション',
|
||||||
|
zh: '选项',
|
||||||
|
vi: 'Tùy chọn',
|
||||||
|
ko: '옵션'
|
||||||
|
}
|
||||||
|
const selectOptionsLabels: Record<string, string> = {
|
||||||
|
th: 'เลือกตัวเลือก',
|
||||||
|
en: 'Select Options',
|
||||||
|
ja: 'オプションを選択',
|
||||||
|
zh: '选择选项',
|
||||||
|
vi: 'Chọn tùy chọn',
|
||||||
|
ko: '옵션 선택'
|
||||||
|
}
|
||||||
|
|
||||||
const addToCartText = addToCartLabels[lang] || addToCartLabels.en
|
const addToCartText = addToCartLabels[lang] || addToCartLabels.en
|
||||||
|
const optionsText = optionsLabels[lang] || optionsLabels.en
|
||||||
|
const selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
||||||
@@ -69,56 +88,51 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.variants.length > 0 && (
|
{product.variants && product.variants.length > 1 && (
|
||||||
<div className="mb-1 mt-2 flex flex-wrap gap-1.5">
|
<div className="mb-1 mt-2 flex items-center">
|
||||||
{product.variants.map((variant) => {
|
<span className="inline-flex items-center gap-1 rounded-md bg-surface-page px-2 py-1 text-[10px] font-semibold text-muted-text border border-line-primary/20 shadow-sm transition-colors group-hover:bg-surface-page/80">
|
||||||
const isSelected = selectedVariant?.id === variant.id
|
<Layers className="h-3 w-3 opacity-70" />
|
||||||
return (
|
{product.variants.length} {optionsText}
|
||||||
<button
|
</span>
|
||||||
key={variant.id}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setSelectedVariant(variant)
|
|
||||||
}}
|
|
||||||
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
|
|
||||||
isSelected
|
|
||||||
? 'border-brand-primary bg-brand-primary text-surface-card shadow-sm'
|
|
||||||
: 'border-line-primary/20 bg-surface-card text-text-main hover:border-brand-primary/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{[variant.option1, variant.option2, variant.option3].filter(Boolean).join(' / ')}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-black text-brand-primary md:text-lg">
|
<span className="text-sm font-black text-brand-primary md:text-lg">
|
||||||
{formatPrice(displayPrice)}
|
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? product.price)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedVariant && (
|
{product.variants && product.variants.length > 1 ? (
|
||||||
|
<Link href={`/products/${product.id}`} className="block w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-button-primary w-full gap-1.5 shadow-sm"
|
||||||
|
>
|
||||||
|
{selectOptionsText}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const targetVariant = selectedVariant || product.variants[0];
|
||||||
|
if (targetVariant) {
|
||||||
addToCart({
|
addToCart({
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
variantId: selectedVariant.id,
|
variantId: targetVariant.id,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
price: selectedVariant.price || 0,
|
price: targetVariant.price || product.price || 0,
|
||||||
productName: productName,
|
productName: productName,
|
||||||
variantName: [selectedVariant.option1, selectedVariant.option2, selectedVariant.option3].filter(Boolean).join(' / '),
|
variantName: [targetVariant.option1, targetVariant.option2, targetVariant.option3].filter(Boolean).join(' / '),
|
||||||
productImage: imageUrl
|
productImage: imageUrl
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
|
className="ui-button-primary w-full gap-1.5 shadow-sm"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="h-3.5 w-3.5" />
|
<ShoppingBag className="h-3.5 w-3.5" />
|
||||||
{addToCartText}
|
{addToCartText}
|
||||||
|
|||||||
Reference in new issue
Block a user