feat: implement i18n for product and category, add 5 new locales
This commit is contained in:
1 parent
b867063efb
commit
96d874233d
17 files changed
+557
-62
No files matched your search
@@ -8,11 +8,13 @@ export default function CategoriesPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<Partial<Category> | 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() {
|
||||
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="w-14 h-14 rounded-2xl overflow-hidden glass border border-white/10 p-1">
|
||||
<img
|
||||
src={cat.imageUrl || getCategoryImage(cat.name)}
|
||||
alt={cat.name}
|
||||
<img
|
||||
src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || ''))}
|
||||
alt={typeof cat.name === 'string' ? cat.name : (cat.name?.th || '')}
|
||||
className="w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +197,9 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 relative z-10">
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">{cat.name}</h3>
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">
|
||||
{typeof cat.name === 'string' ? cat.name : (cat.name?.th || cat.name?.en || 'Unnamed')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
<Package className="w-3 h-3 text-cyan-glow/50" />
|
||||
<span>{cat._count?.products || 0} Products</span>
|
||||
@@ -258,14 +281,36 @@ export default function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Category Name</label>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Category Name</label>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
{languages.map(lang => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setActiveLang(lang)}
|
||||
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase transition-all ${
|
||||
activeLang === lang
|
||||
? 'bg-cyan-glow text-night-900 shadow-lg'
|
||||
: 'text-slate-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingCategory.name || ''}
|
||||
onChange={(e) => 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()}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="mb-2">
|
||||
<h3 className="text-base md:text-lg font-bold text-pop-black leading-tight line-clamp-1 group-hover:text-spa-forest transition-colors">
|
||||
{product.scent.name}
|
||||
{displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
|
||||
{product.category.name}
|
||||
@@ -109,7 +118,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
</span>
|
||||
{!enableQuickShop && (
|
||||
<span className="text-[10px] md:text-xs font-bold text-spa-forest uppercase tracking-widest flex items-center gap-1 group-hover:translate-x-1 transition-transform">
|
||||
View Details →
|
||||
{t('product.viewDetails')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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<Review[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { customer } = useAuthStore()
|
||||
@@ -81,7 +83,7 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
||||
<div className="flex flex-col md:flex-row gap-12 items-start">
|
||||
{/* Summary */}
|
||||
<div className="w-full md:w-1/3 bg-spa-light p-8 rounded-3xl text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold text-pop-black">Reviews</h2>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.reviews')}</h2>
|
||||
<div className="text-6xl font-black text-spa-forest">{avgRating}</div>
|
||||
<div className="flex justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 →"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "詳細を見る →"
|
||||
}
|
||||
}
|
||||
@@ -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": "상세 보기 →"
|
||||
}
|
||||
}
|
||||
@@ -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": "ดูรายละเอียด →"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 →"
|
||||
}
|
||||
}
|
||||
@@ -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": "查看详情 →"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,15 @@ export default function ProductDetailPage() {
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
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() {
|
||||
<Navbar categories={categories} />
|
||||
<div className="pt-40 text-center space-y-4">
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<p className="text-spa-stone font-medium">{error ?? 'Product not found'}</p>
|
||||
<p className="text-spa-stone font-medium">{error ?? t('product.notFound')}</p>
|
||||
<Link to="/shop" className="text-spa-forest hover:underline text-sm font-bold">
|
||||
Back to Shop
|
||||
{t('common.backToShop')}
|
||||
</Link>
|
||||
</div>
|
||||
<Footer categories={categories} />
|
||||
@@ -123,7 +131,7 @@ export default function ProductDetailPage() {
|
||||
variantId: currentVariant.id,
|
||||
quantity: 1,
|
||||
price: currentVariant.price || product.price || 0,
|
||||
productName: product.scent.name,
|
||||
productName: displayName,
|
||||
variantSize: currentVariant.size,
|
||||
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
|
||||
})
|
||||
@@ -147,15 +155,15 @@ export default function ProductDetailPage() {
|
||||
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
|
||||
<Link to="/" className="hover:text-spa-forest transition-colors">Home</Link>
|
||||
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-spa-forest transition-colors">Shop</Link>
|
||||
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-spa-forest">{product.scent.name}</span>
|
||||
<span className="text-spa-forest">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{/* Product Detail */}
|
||||
@@ -177,10 +185,10 @@ export default function ProductDetailPage() {
|
||||
{product.category.name}
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold leading-tight text-pop-black">
|
||||
{product.scent.name}
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
|
||||
Premium Collection
|
||||
{t('product.premiumCollection')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +209,7 @@ export default function ProductDetailPage() {
|
||||
{/* Variant Selector */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
|
||||
Select Size
|
||||
{t('product.selectSize')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
@@ -229,7 +237,7 @@ export default function ProductDetailPage() {
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
Add to Cart
|
||||
{t('cart.addToCart')}
|
||||
</button>
|
||||
<button className="bg-white border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -241,11 +249,11 @@ export default function ProductDetailPage() {
|
||||
{/* Meta */}
|
||||
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Scent</span>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.scent')}</span>
|
||||
<span className="text-pop-black font-bold">{product.scent.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Category</span>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
|
||||
@@ -260,7 +268,7 @@ export default function ProductDetailPage() {
|
||||
{/* Full Details Section */}
|
||||
{resolveTranslatable(product.details, i18n.language) && (
|
||||
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
|
||||
<h2 className="text-3xl font-bold text-pop-black">Product Details</h2>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.details')}</h2>
|
||||
<div
|
||||
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
|
||||
dangerouslySetInnerHTML={{ __html: resolveTranslatable(product.details, i18n.language) }}
|
||||
@@ -277,15 +285,15 @@ export default function ProductDetailPage() {
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
|
||||
More {product.category.name}
|
||||
{t('product.moreRelated', { category: typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.['en'] })}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold text-pop-black">Related Products</h2>
|
||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2>
|
||||
</div>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
|
||||
>
|
||||
View All →
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
@@ -332,7 +340,7 @@ export default function ProductDetailPage() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold">
|
||||
{product.variants && product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
|
||||
{product.variants && product.variants.length > 1 ? currentVariant?.size : t('cart.totalPrice')}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-rose-500">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
@@ -342,7 +350,7 @@ export default function ProductDetailPage() {
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-3.5 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone shadow-sm transition-all flex justify-center items-center gap-2"
|
||||
>
|
||||
Add to Cart
|
||||
{t('cart.addToCart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user