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 [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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +174,9 @@ export default function CategoriesPage() {
|
|||||||
<div key={cat.id} className="admin-card admin-card-hover group relative overflow-hidden">
|
<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="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">
|
||||||
<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
|
<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;
|
||||||
@@ -18,12 +18,25 @@ model Scent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
products Product[]
|
products Product[]
|
||||||
createdAt DateTime @default(now())
|
translations CategoryTranslation[]
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Product {
|
||||||
|
|||||||
@@ -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 } } }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 →
|
{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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 →"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "เลือกซื้อสินค้าต่อ",
|
"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": "ดูรายละเอียด →"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 [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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user