feat: implement i18n for product and category, add 5 new locales

This commit is contained in:
Your Name committed 2026-06-13 23:58:50 -04:00
1 parent b867063efb
commit 96d874233d
17 files changed
+547 -52

No files matched your search

+56 -11
View File
@@ -8,11 +8,13 @@ export default function CategoriesPage() {
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null) const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null)
const [activeLang, setActiveLang] = useState('th')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
try { try {
const r = await fetch(`${API_BASE}/categories`) const r = await fetch(`${API_BASE}/categories?lang=all`)
const data = await r.json() const data = await r.json()
setCategories(data) setCategories(data)
} catch (err) { } catch (err) {
@@ -40,12 +42,26 @@ export default function CategoriesPage() {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: editingCategory.name, name: typeof editingCategory.name === 'string'
? editingCategory.name
: (editingCategory.name?.th || 'Untitled'),
imageUrl: editingCategory.imageUrl || null imageUrl: editingCategory.imageUrl || null
}) })
}) })
if (res.ok) { 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) setIsModalOpen(false)
fetchData() fetchData()
} else { } else {
@@ -117,14 +133,19 @@ export default function CategoriesPage() {
const openAddModal = () => { const openAddModal = () => {
setEditingCategory({ setEditingCategory({
name: '', name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '' } as any,
imageUrl: '' imageUrl: ''
}) })
setActiveLang('th')
setIsModalOpen(true) setIsModalOpen(true)
} }
const openEditModal = (cat: Category) => { const openEditModal = (cat: Category) => {
setEditingCategory(cat) setEditingCategory({
...cat,
name: typeof cat.name === 'string' ? { th: cat.name } as any : { ...cat.name }
})
setActiveLang('th')
setIsModalOpen(true) setIsModalOpen(true)
} }
@@ -154,8 +175,8 @@ export default function CategoriesPage() {
<div className="flex justify-between items-start mb-6"> <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"> <div className="w-14 h-14 rounded-2xl overflow-hidden glass border border-white/10 p-1">
<img <img
src={cat.imageUrl || getCategoryImage(cat.name)} src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || ''))}
alt={cat.name} 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" className="w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-700"
/> />
</div> </div>
@@ -176,7 +197,9 @@ export default function CategoriesPage() {
</div> </div>
<div className="space-y-1 relative z-10"> <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"> <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" /> <Package className="w-3 h-3 text-cyan-glow/50" />
<span>{cat._count?.products || 0} Products</span> <span>{cat._count?.products || 0} Products</span>
@@ -258,14 +281,36 @@ export default function CategoriesPage() {
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<div className="flex justify-between items-end mb-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Category Name</label> <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 <input
type="text" type="text"
required required={activeLang === 'th'}
value={editingCategory.name || ''} value={(editingCategory.name as any)?.[activeLang] || ''}
onChange={(e) => setEditingCategory({ ...editingCategory, name: e.target.value })} onChange={(e) => setEditingCategory({
...editingCategory,
name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any
})}
className="admin-input w-full" className="admin-input w-full"
placeholder="e.g. Reed Diffuser" placeholder={`Category name in ${activeLang.toUpperCase()}`}
/> />
</div> </div>
</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;
+13
View File
@@ -22,10 +22,23 @@ model Category {
name String @unique name String @unique
imageUrl String? imageUrl String?
products Product[] products Product[]
translations CategoryTranslation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 { model Product {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
price Float? @default(0) price Float? @default(0)
+81 -8
View File
@@ -1,17 +1,37 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; 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" }) 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({ const categories = await prisma.category.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
translations: true,
_count: { _count: {
select: { products: true }, select: { products: true },
}, },
}, },
}); });
return categories; return categories.map((cat: any) => ({
...cat,
name: resolveCatName(cat, lang),
}));
}) })
.get("/:id/products", async ({ params, query, status }) => { .get("/:id/products", async ({ params, query, status }) => {
const id = parseInt(params.id); const id = parseInt(params.id);
@@ -27,15 +47,16 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
prisma.category.findUnique({ prisma.category.findUnique({
where: { id }, where: { id },
include: { include: {
translations: true,
products: { products: {
include: { include: {
scent: true, scent: true,
category: true, category: { include: { translations: true } },
translations: true, translations: true,
variants: { orderBy: { price: "asc" as const } }, variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } }, images: { orderBy: { order: "asc" as const } },
}, },
orderBy: { name: "asc" }, orderBy: { id: "asc" },
skip, skip,
take: limit, take: limit,
}, },
@@ -52,7 +73,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
return { return {
category: { category: {
id: category.id, id: category.id,
name: category.name, name: resolveCatName(category, lang),
imageUrl: category.imageUrl, imageUrl: category.imageUrl,
_count: category._count, _count: category._count,
}, },
@@ -66,10 +87,22 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
descObj[t.langCode] = t.description; descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details; 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 { } else {
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0]; 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: { 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 }) => { .post("/", async ({ body, status }) => {
try { try {
const category = await prisma.category.create({ const category = await prisma.category.create({
data: { data: {
name: body.name, name: body.name,
imageUrl: body.imageUrl, imageUrl: body.imageUrl,
// Auto-seed Thai translation
translations: { create: { langCode: 'th', name: body.name } },
}, },
}); });
return category; return category;
@@ -135,7 +209,6 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (isNaN(id)) return status(400, { message: "Invalid category ID" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try { try {
// Ensure no products are attached, or decide if we should cascade delete
const category = await prisma.category.findUnique({ const category = await prisma.category.findUnique({
where: { id }, where: { id },
include: { _count: { select: { products: true } } } include: { _count: { select: { products: true } } }
+19 -5
View File
@@ -34,7 +34,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
prisma.product.findMany({ prisma.product.findMany({
where, where,
include: { include: {
category: true, category: { include: { translations: true } },
scent: true, scent: true,
translations: true, translations: true,
variants: { variants: {
@@ -62,10 +62,17 @@ export const productsRoute = new Elysia({ prefix: "/products" })
descObj[t.langCode] = t.description; descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details; 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 { } else {
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0]; 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({ const product = await prisma.product.findUnique({
where: { id }, where: { id },
include: { include: {
category: true, category: { include: { translations: true } },
scent: true, scent: true,
translations: true, translations: true,
variants: { variants: {
@@ -120,7 +127,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return { ...product, name: nameObj, description: descObj, details: detailsObj }; return { ...product, name: nameObj, description: descObj, details: detailsObj };
} else { } else {
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0]; 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( .put(
+24 -1
View File
@@ -5,7 +5,7 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import axios from "axios"; 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 BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000/api";
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
@@ -87,6 +87,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id"] 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", name: "update_product_content",
description: "Update product basic content (name, description, details)", 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}`); const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; 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": { case "update_product_content": {
const payload: any = {}; const payload: any = {};
if (params.name) payload.name = params.name; if (params.name) payload.name = params.name;
+13 -4
View File
@@ -1,10 +1,17 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState } from 'react'; import { useState } from 'react';
import type { Product } 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 } 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';
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 { interface ProductCardProps {
product: Product; product: Product;
@@ -15,8 +22,10 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
const navigate = useNavigate(); const navigate = useNavigate();
const { addItem, removeItem, isInWishlist } = useWishlistStore(); const { addItem, removeItem, isInWishlist } = useWishlistStore();
const { addToCart } = useCartStore(); const { addToCart } = useCartStore();
const { i18n, t } = useTranslation();
const isWished = isInWishlist(product.id); const isWished = isInWishlist(product.id);
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null); const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
const displayName = resolveStr(product.name, i18n.language) || product.scent.name;
const handleWishlistClick = (e: React.MouseEvent) => { const handleWishlistClick = (e: React.MouseEvent) => {
e.preventDefault(); 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="p-4 flex flex-col flex-1">
<div className="mb-2"> <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"> <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> </h3>
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold"> <p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
{product.category.name} {product.category.name}
@@ -109,7 +118,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
</span> </span>
{!enableQuickShop && ( {!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"> <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 &rarr; {t('product.viewDetails')}
</span> </span>
)} )}
</div> </div>
@@ -125,7 +134,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
variantId: selectedVariant.id, variantId: selectedVariant.id,
quantity: 1, quantity: 1,
price: selectedVariant.price || 0, price: selectedVariant.price || 0,
productName: product.scent.name, productName: displayName,
variantSize: selectedVariant.size, variantSize: selectedVariant.size,
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name) productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
}); });
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { API_BASE } from '../types' import { API_BASE } from '../types'
import { Star } from 'lucide-react' import { Star } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface Review { interface Review {
id: number id: number
@@ -15,6 +16,7 @@ interface Review {
} }
export default function ProductReviews({ productId }: { productId: number }) { export default function ProductReviews({ productId }: { productId: number }) {
const { t } = useTranslation()
const [reviews, setReviews] = useState<Review[]>([]) const [reviews, setReviews] = useState<Review[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { customer } = useAuthStore() 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"> <div className="flex flex-col md:flex-row gap-12 items-start">
{/* Summary */} {/* Summary */}
<div className="w-full md:w-1/3 bg-spa-light p-8 rounded-3xl text-center space-y-4"> <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="text-6xl font-black text-spa-forest">{avgRating}</div>
<div className="flex justify-center gap-1"> <div className="flex justify-center gap-1">
{[1, 2, 3, 4, 5].map(star => ( {[1, 2, 3, 4, 5].map(star => (
+8
View File
@@ -3,6 +3,10 @@ import { initReactI18next } from 'react-i18next'
import enTranslation from './locales/en/translation.json' import enTranslation from './locales/en/translation.json'
import thTranslation from './locales/th/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 // Retrieve default lang from localStorage if set
const savedLang = localStorage.getItem('homaroma_lang') || 'th' const savedLang = localStorage.getItem('homaroma_lang') || 'th'
@@ -13,6 +17,10 @@ i18n
resources: { resources: {
en: { translation: enTranslation }, en: { translation: enTranslation },
th: { translation: thTranslation }, th: { translation: thTranslation },
ja: { translation: jaTranslation },
zh: { translation: zhTranslation },
vi: { translation: viTranslation },
ko: { translation: koTranslation },
}, },
lng: savedLang, // default language (Thai) lng: savedLang, // default language (Thai)
fallbackLng: 'en', fallbackLng: 'en',
+21 -1
View File
@@ -26,7 +26,9 @@
"continueShopping": "Continue Shopping", "continueShopping": "Continue Shopping",
"subtotal": "Subtotal", "subtotal": "Subtotal",
"shippingNote": "Shipping and taxes calculated at checkout.", "shippingNote": "Shipping and taxes calculated at checkout.",
"proceed": "Proceed to Checkout" "proceed": "Proceed to Checkout",
"addToCart": "Add to Cart",
"totalPrice": "Total Price"
}, },
"footer": { "footer": {
"catchphrase": "Elevate your space with premium fragrances.", "catchphrase": "Elevate your space with premium fragrances.",
@@ -35,5 +37,23 @@
"followUs": "Follow Us", "followUs": "Follow Us",
"contactUs": "Contact Us", "contactUs": "Contact Us",
"rights": "All rights reserved." "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 →"
} }
} }
+59
View File
@@ -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": "詳細を見る →"
}
}
+59
View File
@@ -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": "상세 보기 →"
}
}
+21 -1
View File
@@ -26,7 +26,9 @@
"continueShopping": "เลือกซื้อสินค้าต่อ", "continueShopping": "เลือกซื้อสินค้าต่อ",
"subtotal": "ยอดรวมยอด", "subtotal": "ยอดรวมยอด",
"shippingNote": "ค่าจัดส่งและภาษีจะถูกคำนวณท้ายสุด", "shippingNote": "ค่าจัดส่งและภาษีจะถูกคำนวณท้ายสุด",
"proceed": "ดำเนินการชำระเงิน" "proceed": "ดำเนินการชำระเงิน",
"addToCart": "เพิ่มลงตะกร้า",
"totalPrice": "ราคารวม"
}, },
"footer": { "footer": {
"catchphrase": "ยกระดับพื้นที่ของคุณด้วยน้ำหอมระดับพรีเมียม", "catchphrase": "ยกระดับพื้นที่ของคุณด้วยน้ำหอมระดับพรีเมียม",
@@ -35,5 +37,23 @@
"followUs": "ติดตามเรา", "followUs": "ติดตามเรา",
"contactUs": "ติดต่อสอบถาม", "contactUs": "ติดต่อสอบถาม",
"rights": "สงวนลิขสิทธิ์." "rights": "สงวนลิขสิทธิ์."
},
"common": {
"home": "หน้าหลัก",
"shop": "ร้านค้า",
"viewAll": "ดูทั้งหมด →",
"backToShop": "กลับไปร้านค้า"
},
"product": {
"notFound": "ไม่พบสินค้า",
"premiumCollection": "พรีเมียมคอลเลกชัน",
"selectSize": "เลือกขนาด",
"details": "รายละเอียดสินค้า",
"scent": "กลิ่น",
"category": "หมวดหมู่",
"relatedProducts": "สินค้าที่เกี่ยวข้อง",
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
"reviews": "รีวิว",
"viewDetails": "ดูรายละเอียด →"
} }
} }
+59
View File
@@ -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 →"
}
}
+59
View File
@@ -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": "查看详情 →"
}
}
+27 -19
View File
@@ -30,7 +30,15 @@ export default function ProductDetailPage() {
const [cmsSections, setCmsSections] = useState<PageSection[]>([]) const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { addToCart } = useCartStore() 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(() => { useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`) fetch(`${API_BASE}/categories?lang=${i18n.language}`)
@@ -104,9 +112,9 @@ export default function ProductDetailPage() {
<Navbar categories={categories} /> <Navbar categories={categories} />
<div className="pt-40 text-center space-y-4"> <div className="pt-40 text-center space-y-4">
<p className="text-4xl">⚠️</p> <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"> <Link to="/shop" className="text-spa-forest hover:underline text-sm font-bold">
Back to Shop {t('common.backToShop')}
</Link> </Link>
</div> </div>
<Footer categories={categories} /> <Footer categories={categories} />
@@ -123,7 +131,7 @@ export default function ProductDetailPage() {
variantId: currentVariant.id, variantId: currentVariant.id,
quantity: 1, quantity: 1,
price: currentVariant.price || product.price || 0, price: currentVariant.price || product.price || 0,
productName: product.scent.name, productName: displayName,
variantSize: currentVariant.size, variantSize: currentVariant.size,
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name) 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"> <div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
{/* Breadcrumb */} {/* 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"> <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> <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> <span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors"> <Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{product.category.name} {product.category.name}
</Link> </Link>
<span>/</span> <span>/</span>
<span className="text-spa-forest">{product.scent.name}</span> <span className="text-spa-forest">{displayName}</span>
</div> </div>
{/* Product Detail */} {/* Product Detail */}
@@ -177,10 +185,10 @@ export default function ProductDetailPage() {
{product.category.name} {product.category.name}
</p> </p>
<h1 className="text-5xl font-bold leading-tight text-pop-black"> <h1 className="text-5xl font-bold leading-tight text-pop-black">
{product.scent.name} {displayName}
</h1> </h1>
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold"> <p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
Premium Collection {t('product.premiumCollection')}
</p> </p>
</div> </div>
@@ -201,7 +209,7 @@ export default function ProductDetailPage() {
{/* Variant Selector */} {/* Variant Selector */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold"> <p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
Select Size {t('product.selectSize')}
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => ( {product.variants.map((v, i) => (
@@ -229,7 +237,7 @@ export default function ProductDetailPage() {
onClick={handleAddToCart} 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" 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>
<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"> <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"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -241,11 +249,11 @@ export default function ProductDetailPage() {
{/* Meta */} {/* Meta */}
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80"> <div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div> <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> <span className="text-pop-black font-bold">{product.scent.name}</span>
</div> </div>
<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 <Link
to={`/category/${product.category.id}`} to={`/category/${product.category.id}`}
className="text-pop-black font-bold hover:text-spa-forest transition-colors" className="text-pop-black font-bold hover:text-spa-forest transition-colors"
@@ -260,7 +268,7 @@ export default function ProductDetailPage() {
{/* Full Details Section */} {/* Full Details Section */}
{resolveTranslatable(product.details, i18n.language) && ( {resolveTranslatable(product.details, i18n.language) && (
<div className="pt-16 border-t border-spa-stone/10 space-y-6"> <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 <div
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone" className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
dangerouslySetInnerHTML={{ __html: resolveTranslatable(product.details, i18n.language) }} dangerouslySetInnerHTML={{ __html: resolveTranslatable(product.details, i18n.language) }}
@@ -277,15 +285,15 @@ export default function ProductDetailPage() {
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
<div> <div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2"> <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> </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> </div>
<Link <Link
to={`/category/${product.category.id}`} to={`/category/${product.category.id}`}
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline" className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
> >
View All → {t('common.viewAll')}
</Link> </Link>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8"> <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 items-center justify-between gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold"> <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>
<span className="text-xl font-bold text-rose-500"> <span className="text-xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)} {formatPrice(currentVariant?.price)}
@@ -342,7 +350,7 @@ export default function ProductDetailPage() {
onClick={handleAddToCart} 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" 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> </button>
</div> </div>
</div> </div>
+3 -1
View File
@@ -6,13 +6,15 @@ export interface Scent {
export interface Category { export interface Category {
id: number id: number
name: string name: TranslatableString
imageUrl?: string | null imageUrl?: string | null
_count?: { products: number } _count?: { products: number }
translations?: { langCode: string; name: string }[]
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
} }
export interface Variant { export interface Variant {
id: number id: number
size: string size: string