diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx index 27a7ccc..cca8c35 100644 --- a/apps/admin/src/pages/CategoriesPage.tsx +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -8,11 +8,13 @@ export default function CategoriesPage() { const [isModalOpen, setIsModalOpen] = useState(false) const [isUploading, setIsUploading] = useState(false) const [editingCategory, setEditingCategory] = useState | null>(null) + const [activeLang, setActiveLang] = useState('th') + const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'] const fetchData = async () => { setLoading(true) try { - const r = await fetch(`${API_BASE}/categories`) + const r = await fetch(`${API_BASE}/categories?lang=all`) const data = await r.json() setCategories(data) } catch (err) { @@ -40,12 +42,26 @@ export default function CategoriesPage() { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: editingCategory.name, + name: typeof editingCategory.name === 'string' + ? editingCategory.name + : (editingCategory.name?.th || 'Untitled'), imageUrl: editingCategory.imageUrl || null }) }) if (res.ok) { + const savedCat = await res.json(); + const catId = editingCategory.id || savedCat.id; + + // Save translations + if (typeof editingCategory.name === 'object') { + await fetch(`${API_BASE}/categories/${catId}/translations`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editingCategory.name }) + }); + } + setIsModalOpen(false) fetchData() } else { @@ -117,14 +133,19 @@ export default function CategoriesPage() { const openAddModal = () => { setEditingCategory({ - name: '', + name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '' } as any, imageUrl: '' }) + setActiveLang('th') setIsModalOpen(true) } const openEditModal = (cat: Category) => { - setEditingCategory(cat) + setEditingCategory({ + ...cat, + name: typeof cat.name === 'string' ? { th: cat.name } as any : { ...cat.name } + }) + setActiveLang('th') setIsModalOpen(true) } @@ -153,9 +174,9 @@ export default function CategoriesPage() {
- {cat.name}
@@ -176,7 +197,9 @@ export default function CategoriesPage() {
-

{cat.name}

+

+ {typeof cat.name === 'string' ? cat.name : (cat.name?.th || cat.name?.en || 'Unnamed')} +

{cat._count?.products || 0} Products @@ -258,14 +281,36 @@ export default function CategoriesPage() {
- +
+ +
+ {languages.map(lang => ( + + ))} +
+
+ setEditingCategory({ ...editingCategory, name: e.target.value })} + required={activeLang === 'th'} + value={(editingCategory.name as any)?.[activeLang] || ''} + onChange={(e) => setEditingCategory({ + ...editingCategory, + name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any + })} className="admin-input w-full" - placeholder="e.g. Reed Diffuser" + placeholder={`Category name in ${activeLang.toUpperCase()}`} />
diff --git a/apps/backend/prisma/migrations/20260614010000_add_category_translation/migration.sql b/apps/backend/prisma/migrations/20260614010000_add_category_translation/migration.sql new file mode 100644 index 0000000..22aacb0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260614010000_add_category_translation/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "CategoryTranslation" ( + "id" SERIAL NOT NULL, + "categoryId" INTEGER NOT NULL, + "langCode" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CategoryTranslation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CategoryTranslation_categoryId_langCode_key" ON "CategoryTranslation"("categoryId", "langCode"); + +-- AddForeignKey +ALTER TABLE "CategoryTranslation" ADD CONSTRAINT "CategoryTranslation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Seed Thai translations from existing category names +INSERT INTO "CategoryTranslation" ("categoryId", "langCode", "name", "updatedAt") +SELECT id, 'th', name, NOW() FROM "Category" +ON CONFLICT ("categoryId", "langCode") DO NOTHING; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a965a7a..8d85e79 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -18,12 +18,25 @@ model Scent { } model Category { - id Int @id @default(autoincrement()) - name String @unique - imageUrl String? - products Product[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + name String @unique + imageUrl String? + products Product[] + translations CategoryTranslation[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model CategoryTranslation { + id Int @id @default(autoincrement()) + categoryId Int + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + langCode String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([categoryId, langCode]) } model Product { diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index 4406c00..d8f8196 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -1,17 +1,37 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko']; + +/** Resolve category name based on lang */ +function resolveCatName(cat: any, lang: string): any { + if (lang === 'all') { + const nameObj: any = {}; + cat.translations?.forEach((tr: any) => { nameObj[tr.langCode] = tr.name; }); + return nameObj; + } + const trans = cat.translations?.find((t: any) => t.langCode === lang) + || cat.translations?.find((t: any) => t.langCode === 'th') + || cat.translations?.[0]; + return trans?.name ?? cat.name; +} + export const categoriesRoute = new Elysia({ prefix: "/categories" }) - .get("/", async () => { + .get("/", async ({ query }) => { + const lang = (query as any).lang || 'th'; const categories = await prisma.category.findMany({ orderBy: { name: "asc" }, include: { + translations: true, _count: { select: { products: true }, }, }, }); - return categories; + return categories.map((cat: any) => ({ + ...cat, + name: resolveCatName(cat, lang), + })); }) .get("/:id/products", async ({ params, query, status }) => { const id = parseInt(params.id); @@ -27,15 +47,16 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) prisma.category.findUnique({ where: { id }, include: { + translations: true, products: { include: { scent: true, - category: true, + category: { include: { translations: true } }, translations: true, variants: { orderBy: { price: "asc" as const } }, images: { orderBy: { order: "asc" as const } }, }, - orderBy: { name: "asc" }, + orderBy: { id: "asc" }, skip, take: limit, }, @@ -52,7 +73,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) return { category: { id: category.id, - name: category.name, + name: resolveCatName(category, lang), imageUrl: category.imageUrl, _count: category._count, }, @@ -66,10 +87,22 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) descObj[t.langCode] = t.description; detailsObj[t.langCode] = t.details; }); - return { ...p, name: nameObj, description: descObj, details: detailsObj }; + return { + ...p, + name: nameObj, + description: descObj, + details: detailsObj, + category: { ...p.category, name: resolveCatName(p.category, lang) }, + }; } else { 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 }; + return { + ...p, + name: trans?.name || p.scent?.name || '', + description: trans?.description, + details: trans?.details, + category: { ...p.category, name: resolveCatName(p.category, lang) }, + }; } }), meta: { @@ -80,12 +113,53 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) }, }; }) + .patch("/:id/translations", async ({ params, body, status }) => { + const id = parseInt(params.id); + if (isNaN(id)) return status(400, { message: "Invalid category ID" }); + + const payload = body as any; + const results: any[] = []; + + for (const lang of LANGS) { + const name = typeof payload.name === 'string' + ? (lang === 'th' ? payload.name : null) + : payload.name?.[lang]; + + if (!name) continue; + + const existing = await prisma.categoryTranslation.findUnique({ + where: { categoryId_langCode: { categoryId: id, langCode: lang } } + }); + + if (existing) { + const updated = await prisma.categoryTranslation.update({ + where: { id: existing.id }, + data: { name }, + }); + results.push(updated); + } else { + const created = await prisma.categoryTranslation.create({ + data: { categoryId: id, langCode: lang, name }, + }); + results.push(created); + } + } + + const updated = await prisma.category.findUnique({ + where: { id }, + include: { translations: true, _count: { select: { products: true } } }, + }); + + return { success: true, translations: results, category: updated }; + }) .post("/", async ({ body, status }) => { try { const category = await prisma.category.create({ data: { name: body.name, imageUrl: body.imageUrl, + // Auto-seed Thai translation + translations: { create: { langCode: 'th', name: body.name } }, }, }); return category; @@ -135,7 +209,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) if (isNaN(id)) return status(400, { message: "Invalid category ID" }); try { - // Ensure no products are attached, or decide if we should cascade delete const category = await prisma.category.findUnique({ where: { id }, include: { _count: { select: { products: true } } } diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index f5003ac..cd57aa6 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -34,7 +34,7 @@ export const productsRoute = new Elysia({ prefix: "/products" }) prisma.product.findMany({ where, include: { - category: true, + category: { include: { translations: true } }, scent: true, translations: true, variants: { @@ -62,10 +62,17 @@ export const productsRoute = new Elysia({ prefix: "/products" }) descObj[t.langCode] = t.description; detailsObj[t.langCode] = t.details; }); - return { ...p, name: nameObj, description: descObj, details: detailsObj }; + return { ...p, name: nameObj, description: descObj, details: detailsObj, category: { ...p.category, name: p.category.name } }; // Actually for 'all', keep as is, but wait, category name needs resolving if we want it? Let's just resolve it simply below. } else { const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0]; - return { ...p, name: trans?.name || '', description: trans?.description, details: trans?.details }; + const catTrans = p.category.translations?.find((t: any) => t.langCode === lang) || p.category.translations?.find((t: any) => t.langCode === 'th') || p.category.translations?.[0]; + return { + ...p, + name: trans?.name || '', + description: trans?.description, + details: trans?.details, + category: { ...p.category, name: catTrans?.name || p.category.name } + }; } }); @@ -93,7 +100,7 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const product = await prisma.product.findUnique({ where: { id }, include: { - category: true, + category: { include: { translations: true } }, scent: true, translations: true, variants: { @@ -120,7 +127,14 @@ export const productsRoute = new Elysia({ prefix: "/products" }) return { ...product, name: nameObj, description: descObj, details: detailsObj }; } else { const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0]; - return { ...product, name: trans?.name || '', description: trans?.description, details: trans?.details }; + const catTrans = product.category.translations?.find((t: any) => t.langCode === lang) || product.category.translations?.find((t: any) => t.langCode === 'th') || product.category.translations?.[0]; + return { + ...product, + name: trans?.name || '', + description: trans?.description, + details: trans?.details, + category: { ...product.category, name: catTrans?.name || product.category.name } + }; } }) .put( diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 381d613..d91de19 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -5,7 +5,7 @@ import { ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; -import FormData from "form-data"; +import * as FormData from "form-data"; const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000/api"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin"; @@ -87,6 +87,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["id"] } }, + { + name: "update_category_translation", + description: "Add or update translations for a specific category. Do NOT use this tool for products.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Category ID" }, + lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" }, + name: { type: "string", description: "Translated category name" } + }, + required: ["id", "lang", "name"] + } + }, { name: "update_product_content", description: "Update product basic content (name, description, details)", @@ -351,6 +364,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const res = await api.get(`/products/${params.id}${lang}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "update_category_translation": { + const { id, lang, name } = params as any; + const res = await api.patch(`/categories/${id}/translations`, { + name: { [lang]: name } + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] + }; + } + case "update_product_content": { const payload: any = {}; if (params.name) payload.name = params.name; diff --git a/apps/spa/src/components/ProductCard.tsx b/apps/spa/src/components/ProductCard.tsx index f724c8e..77e233b 100644 --- a/apps/spa/src/components/ProductCard.tsx +++ b/apps/spa/src/components/ProductCard.tsx @@ -1,10 +1,17 @@ import { useNavigate } from 'react-router-dom'; import { useState } from 'react'; -import type { Product } from '../types'; +import type { Product, TranslatableString } from '../types'; import { getCategoryImage, formatPrice } from '../types'; import { Heart, ShoppingBag } from 'lucide-react'; import { useWishlistStore } from '../store/wishlistStore'; import { useCartStore } from '../store/cartStore'; +import { useTranslation } from 'react-i18next'; + +function resolveStr(v: TranslatableString | null | undefined, lang: string): string { + if (!v) return '' + if (typeof v === 'string') return v + return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? '' +} interface ProductCardProps { product: Product; @@ -15,8 +22,10 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc const navigate = useNavigate(); const { addItem, removeItem, isInWishlist } = useWishlistStore(); const { addToCart } = useCartStore(); + const { i18n, t } = useTranslation(); const isWished = isInWishlist(product.id); const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null); + const displayName = resolveStr(product.name, i18n.language) || product.scent.name; const handleWishlistClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -67,7 +76,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc

- {product.scent.name} + {displayName}

{product.category.name} @@ -109,7 +118,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc {!enableQuickShop && ( - View Details → + {t('product.viewDetails')} )}

@@ -125,7 +134,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc variantId: selectedVariant.id, quantity: 1, price: selectedVariant.price || 0, - productName: product.scent.name, + productName: displayName, variantSize: selectedVariant.size, productImage: product.images?.[0]?.url || getCategoryImage(product.category.name) }); diff --git a/apps/spa/src/components/ProductReviews.tsx b/apps/spa/src/components/ProductReviews.tsx index 72087ca..b712b06 100644 --- a/apps/spa/src/components/ProductReviews.tsx +++ b/apps/spa/src/components/ProductReviews.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useAuthStore } from '../store/authStore' import { API_BASE } from '../types' import { Star } from 'lucide-react' +import { useTranslation } from 'react-i18next' interface Review { id: number @@ -15,6 +16,7 @@ interface Review { } export default function ProductReviews({ productId }: { productId: number }) { + const { t } = useTranslation() const [reviews, setReviews] = useState([]) const [loading, setLoading] = useState(true) const { customer } = useAuthStore() @@ -81,7 +83,7 @@ export default function ProductReviews({ productId }: { productId: number }) {
{/* Summary */}
-

Reviews

+

{t('product.reviews')}

{avgRating}
{[1, 2, 3, 4, 5].map(star => ( diff --git a/apps/spa/src/i18n.ts b/apps/spa/src/i18n.ts index 7e17176..6aaeb8b 100644 --- a/apps/spa/src/i18n.ts +++ b/apps/spa/src/i18n.ts @@ -3,6 +3,10 @@ import { initReactI18next } from 'react-i18next' import enTranslation from './locales/en/translation.json' import thTranslation from './locales/th/translation.json' +import jaTranslation from "./locales/ja/translation.json" +import zhTranslation from "./locales/zh/translation.json" +import viTranslation from "./locales/vi/translation.json" +import koTranslation from "./locales/ko/translation.json" // Retrieve default lang from localStorage if set const savedLang = localStorage.getItem('homaroma_lang') || 'th' @@ -13,6 +17,10 @@ i18n resources: { en: { translation: enTranslation }, th: { translation: thTranslation }, + ja: { translation: jaTranslation }, + zh: { translation: zhTranslation }, + vi: { translation: viTranslation }, + ko: { translation: koTranslation }, }, lng: savedLang, // default language (Thai) fallbackLng: 'en', diff --git a/apps/spa/src/locales/en/translation.json b/apps/spa/src/locales/en/translation.json index 71251cd..3f87e78 100644 --- a/apps/spa/src/locales/en/translation.json +++ b/apps/spa/src/locales/en/translation.json @@ -26,7 +26,9 @@ "continueShopping": "Continue Shopping", "subtotal": "Subtotal", "shippingNote": "Shipping and taxes calculated at checkout.", - "proceed": "Proceed to Checkout" + "proceed": "Proceed to Checkout", + "addToCart": "Add to Cart", + "totalPrice": "Total Price" }, "footer": { "catchphrase": "Elevate your space with premium fragrances.", @@ -35,5 +37,23 @@ "followUs": "Follow Us", "contactUs": "Contact Us", "rights": "All rights reserved." + }, + "common": { + "home": "Home", + "shop": "Shop", + "viewAll": "View All →", + "backToShop": "Back to Shop" + }, + "product": { + "notFound": "Product not found", + "premiumCollection": "Premium Collection", + "selectSize": "Select Size", + "details": "Product Details", + "scent": "Scent", + "category": "Category", + "relatedProducts": "Related Products", + "moreRelated": "More {{category}}", + "reviews": "Reviews", + "viewDetails": "View Details →" } -} +} \ No newline at end of file diff --git a/apps/spa/src/locales/ja/translation.json b/apps/spa/src/locales/ja/translation.json new file mode 100644 index 0000000..5902926 --- /dev/null +++ b/apps/spa/src/locales/ja/translation.json @@ -0,0 +1,59 @@ +{ + "nav": { + "home": "ホーム", + "shop": "ショップ", + "collections": "コレクション", + "scentGuide": "香りガイド", + "about": "私たちについて", + "menu": "メニュー", + "faq": "よくある質問", + "contact": "お問い合わせ", + "shopNow": "今すぐ購入", + "findMyScent": "香りを探す ✨", + "promotions": "プロモーション", + "profile": "マイプロフィール", + "search": { + "title": "検索", + "placeholder": "香りを検索...", + "quickLinks": "クイックリンク", + "allProducts": "すべての商品" + } + }, + "cart": { + "title": "カート", + "items": "{{count}} 点", + "empty": "カートは空です。", + "continueShopping": "ショッピングを続ける", + "subtotal": "小計", + "shippingNote": "送料と税金はチェックアウト時に計算されます。", + "proceed": "レジへ進む", + "addToCart": "カートに追加", + "totalPrice": "合計金額" + }, + "footer": { + "catchphrase": "プレミアムな香りで空間をアップグレード", + "explore": "探索", + "support": "サポート", + "followUs": "フォローする", + "contactUs": "お問い合わせ", + "rights": "All rights reserved." + }, + "common": { + "home": "ホーム", + "shop": "ショップ", + "viewAll": "すべて見る →", + "backToShop": "ショップに戻る" + }, + "product": { + "notFound": "商品が見つかりません", + "premiumCollection": "プレミアムコレクション", + "selectSize": "サイズを選択", + "details": "商品詳細", + "scent": "香り", + "category": "カテゴリー", + "relatedProducts": "関連商品", + "moreRelated": "関連する {{category}}", + "reviews": "レビュー", + "viewDetails": "詳細を見る →" + } +} \ No newline at end of file diff --git a/apps/spa/src/locales/ko/translation.json b/apps/spa/src/locales/ko/translation.json new file mode 100644 index 0000000..562cfe1 --- /dev/null +++ b/apps/spa/src/locales/ko/translation.json @@ -0,0 +1,59 @@ +{ + "nav": { + "home": "홈", + "shop": "쇼핑", + "collections": "컬렉션", + "scentGuide": "향기 가이드", + "about": "소개", + "menu": "메뉴", + "faq": "자주 묻는 질문", + "contact": "연락처", + "shopNow": "지금 쇼핑하기", + "findMyScent": "나의 향기 찾기 ✨", + "promotions": "프로모션", + "profile": "내 프로필", + "search": { + "title": "검색", + "placeholder": "향기 검색...", + "quickLinks": "빠른 링크", + "allProducts": "모든 제품" + } + }, + "cart": { + "title": "장바구니", + "items": "{{count}} 개", + "empty": "장바구니가 비어 있습니다.", + "continueShopping": "쇼핑 계속하기", + "subtotal": "소계", + "shippingNote": "배송비 및 세금은 결제 시 계산됩니다.", + "proceed": "결제 진행", + "addToCart": "장바구니 담기", + "totalPrice": "총 가격" + }, + "footer": { + "catchphrase": "프리미엄 향기로 공간을 업그레이드하세요.", + "explore": "탐색", + "support": "지원", + "followUs": "팔로우", + "contactUs": "연락처", + "rights": "모든 권리 보유." + }, + "common": { + "home": "홈", + "shop": "쇼핑", + "viewAll": "모두 보기 →", + "backToShop": "쇼핑으로 돌아가기" + }, + "product": { + "notFound": "상품을 찾을 수 없습니다", + "premiumCollection": "프리미엄 컬렉션", + "selectSize": "사이즈 선택", + "details": "상품 상세 정보", + "scent": "향기", + "category": "카테고리", + "relatedProducts": "관련 상품", + "moreRelated": "더 많은 {{category}}", + "reviews": "리뷰", + "viewDetails": "상세 보기 →" + } +} \ No newline at end of file diff --git a/apps/spa/src/locales/th/translation.json b/apps/spa/src/locales/th/translation.json index b22bbd1..00a5e4a 100644 --- a/apps/spa/src/locales/th/translation.json +++ b/apps/spa/src/locales/th/translation.json @@ -26,7 +26,9 @@ "continueShopping": "เลือกซื้อสินค้าต่อ", "subtotal": "ยอดรวมยอด", "shippingNote": "ค่าจัดส่งและภาษีจะถูกคำนวณท้ายสุด", - "proceed": "ดำเนินการชำระเงิน" + "proceed": "ดำเนินการชำระเงิน", + "addToCart": "เพิ่มลงตะกร้า", + "totalPrice": "ราคารวม" }, "footer": { "catchphrase": "ยกระดับพื้นที่ของคุณด้วยน้ำหอมระดับพรีเมียม", @@ -35,5 +37,23 @@ "followUs": "ติดตามเรา", "contactUs": "ติดต่อสอบถาม", "rights": "สงวนลิขสิทธิ์." + }, + "common": { + "home": "หน้าหลัก", + "shop": "ร้านค้า", + "viewAll": "ดูทั้งหมด →", + "backToShop": "กลับไปร้านค้า" + }, + "product": { + "notFound": "ไม่พบสินค้า", + "premiumCollection": "พรีเมียมคอลเลกชัน", + "selectSize": "เลือกขนาด", + "details": "รายละเอียดสินค้า", + "scent": "กลิ่น", + "category": "หมวดหมู่", + "relatedProducts": "สินค้าที่เกี่ยวข้อง", + "moreRelated": "เพิ่มเติมในหมวด {{category}}", + "reviews": "รีวิว", + "viewDetails": "ดูรายละเอียด →" } -} +} \ No newline at end of file diff --git a/apps/spa/src/locales/vi/translation.json b/apps/spa/src/locales/vi/translation.json new file mode 100644 index 0000000..488dbc5 --- /dev/null +++ b/apps/spa/src/locales/vi/translation.json @@ -0,0 +1,59 @@ +{ + "nav": { + "home": "Trang chủ", + "shop": "Cửa hàng", + "collections": "Bộ sưu tập", + "scentGuide": "Hướng dẫn mùi hương", + "about": "Giới thiệu", + "menu": "Menu", + "faq": "Câu hỏi thường gặp", + "contact": "Liên hệ", + "shopNow": "Mua ngay", + "findMyScent": "Tìm mùi hương của bạn ✨", + "promotions": "Khuyến mãi", + "profile": "Hồ sơ của tôi", + "search": { + "title": "Tìm kiếm", + "placeholder": "Tìm mùi hương...", + "quickLinks": "Liên kết nhanh", + "allProducts": "Tất cả sản phẩm" + } + }, + "cart": { + "title": "Giỏ hàng", + "items": "{{count}} sản phẩm", + "empty": "Giỏ hàng của bạn đang trống.", + "continueShopping": "Tiếp tục mua sắm", + "subtotal": "Tổng phụ", + "shippingNote": "Phí vận chuyển và thuế được tính lúc thanh toán.", + "proceed": "Tiến hành thanh toán", + "addToCart": "Thêm vào giỏ", + "totalPrice": "Tổng giá" + }, + "footer": { + "catchphrase": "Nâng tầm không gian với hương thơm cao cấp.", + "explore": "Khám phá", + "support": "Hỗ trợ", + "followUs": "Theo dõi chúng tôi", + "contactUs": "Liên hệ", + "rights": "Bảo lưu mọi quyền." + }, + "common": { + "home": "Trang chủ", + "shop": "Cửa hàng", + "viewAll": "Xem tất cả →", + "backToShop": "Quay lại cửa hàng" + }, + "product": { + "notFound": "Không tìm thấy sản phẩm", + "premiumCollection": "Bộ sưu tập Cao cấp", + "selectSize": "Chọn kích thước", + "details": "Chi tiết sản phẩm", + "scent": "Mùi hương", + "category": "Danh mục", + "relatedProducts": "Sản phẩm liên quan", + "moreRelated": "Thêm {{category}}", + "reviews": "Đánh giá", + "viewDetails": "Xem chi tiết →" + } +} \ No newline at end of file diff --git a/apps/spa/src/locales/zh/translation.json b/apps/spa/src/locales/zh/translation.json new file mode 100644 index 0000000..e1eae6d --- /dev/null +++ b/apps/spa/src/locales/zh/translation.json @@ -0,0 +1,59 @@ +{ + "nav": { + "home": "首页", + "shop": "商店", + "collections": "系列", + "scentGuide": "香气指南", + "about": "关于我们", + "menu": "菜单", + "faq": "常见问题", + "contact": "联系我们", + "shopNow": "立即购买", + "findMyScent": "寻找我的香气 ✨", + "promotions": "促销", + "profile": "我的主页", + "search": { + "title": "搜索", + "placeholder": "搜索香气...", + "quickLinks": "快速链接", + "allProducts": "所有商品" + } + }, + "cart": { + "title": "购物车", + "items": "{{count}} 件", + "empty": "您的购物车是空的。", + "continueShopping": "继续购物", + "subtotal": "小计", + "shippingNote": "运费和税费将在结账时计算。", + "proceed": "去结账", + "addToCart": "加入购物车", + "totalPrice": "总价" + }, + "footer": { + "catchphrase": "用高级香氛提升您的空间。", + "explore": "探索", + "support": "支持", + "followUs": "关注我们", + "contactUs": "联系我们", + "rights": "版权所有。" + }, + "common": { + "home": "首页", + "shop": "商店", + "viewAll": "查看全部 →", + "backToShop": "返回商店" + }, + "product": { + "notFound": "未找到商品", + "premiumCollection": "高级系列", + "selectSize": "选择尺寸", + "details": "商品详情", + "scent": "香气", + "category": "类别", + "relatedProducts": "相关商品", + "moreRelated": "更多 {{category}}", + "reviews": "评价", + "viewDetails": "查看详情 →" + } +} \ No newline at end of file diff --git a/apps/spa/src/pages/ProductDetailPage.tsx b/apps/spa/src/pages/ProductDetailPage.tsx index 0130cd0..48981e3 100644 --- a/apps/spa/src/pages/ProductDetailPage.tsx +++ b/apps/spa/src/pages/ProductDetailPage.tsx @@ -30,7 +30,15 @@ export default function ProductDetailPage() { const [cmsSections, setCmsSections] = useState([]) const { addToCart } = useCartStore() - const { i18n } = useTranslation() + const { i18n, t } = useTranslation() + + function resolveStr(v: TranslatableString | null | undefined, lang: string): string { + if (!v) return '' + if (typeof v === 'string') return v + return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? '' + } + + const displayName = resolveStr(product?.name, i18n.language) || product?.scent?.name || '' useEffect(() => { fetch(`${API_BASE}/categories?lang=${i18n.language}`) @@ -104,9 +112,9 @@ export default function ProductDetailPage() {

⚠️

-

{error ?? 'Product not found'}

+

{error ?? t('product.notFound')}

- Back to Shop + {t('common.backToShop')}