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
+557 -62

No files matched your search

+13 -4
View File
@@ -1,10 +1,17 @@
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import type { Product } from '../types';
import type { Product, TranslatableString } from '../types';
import { getCategoryImage, formatPrice } from '../types';
import { Heart, ShoppingBag } from 'lucide-react';
import { useWishlistStore } from '../store/wishlistStore';
import { useCartStore } from '../store/cartStore';
import { useTranslation } from 'react-i18next';
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
if (!v) return ''
if (typeof v === 'string') return v
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
}
interface ProductCardProps {
product: Product;
@@ -15,8 +22,10 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
const navigate = useNavigate();
const { addItem, removeItem, isInWishlist } = useWishlistStore();
const { addToCart } = useCartStore();
const { i18n, t } = useTranslation();
const isWished = isInWishlist(product.id);
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
const displayName = resolveStr(product.name, i18n.language) || product.scent.name;
const handleWishlistClick = (e: React.MouseEvent) => {
e.preventDefault();
@@ -67,7 +76,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
<div className="p-4 flex flex-col flex-1">
<div className="mb-2">
<h3 className="text-base md:text-lg font-bold text-pop-black leading-tight line-clamp-1 group-hover:text-spa-forest transition-colors">
{product.scent.name}
{displayName}
</h3>
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
{product.category.name}
@@ -109,7 +118,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
</span>
{!enableQuickShop && (
<span className="text-[10px] md:text-xs font-bold text-spa-forest uppercase tracking-widest flex items-center gap-1 group-hover:translate-x-1 transition-transform">
View Details &rarr;
{t('product.viewDetails')}
</span>
)}
</div>
@@ -125,7 +134,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
variantId: selectedVariant.id,
quantity: 1,
price: selectedVariant.price || 0,
productName: product.scent.name,
productName: displayName,
variantSize: selectedVariant.size,
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
});
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useAuthStore } from '../store/authStore'
import { API_BASE } from '../types'
import { Star } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface Review {
id: number
@@ -15,6 +16,7 @@ interface Review {
}
export default function ProductReviews({ productId }: { productId: number }) {
const { t } = useTranslation()
const [reviews, setReviews] = useState<Review[]>([])
const [loading, setLoading] = useState(true)
const { customer } = useAuthStore()
@@ -81,7 +83,7 @@ export default function ProductReviews({ productId }: { productId: number }) {
<div className="flex flex-col md:flex-row gap-12 items-start">
{/* Summary */}
<div className="w-full md:w-1/3 bg-spa-light p-8 rounded-3xl text-center space-y-4">
<h2 className="text-3xl font-bold text-pop-black">Reviews</h2>
<h2 className="text-3xl font-bold text-pop-black">{t('product.reviews')}</h2>
<div className="text-6xl font-black text-spa-forest">{avgRating}</div>
<div className="flex justify-center gap-1">
{[1, 2, 3, 4, 5].map(star => (
+8
View File
@@ -3,6 +3,10 @@ import { initReactI18next } from 'react-i18next'
import enTranslation from './locales/en/translation.json'
import thTranslation from './locales/th/translation.json'
import jaTranslation from "./locales/ja/translation.json"
import zhTranslation from "./locales/zh/translation.json"
import viTranslation from "./locales/vi/translation.json"
import koTranslation from "./locales/ko/translation.json"
// Retrieve default lang from localStorage if set
const savedLang = localStorage.getItem('homaroma_lang') || 'th'
@@ -13,6 +17,10 @@ i18n
resources: {
en: { translation: enTranslation },
th: { translation: thTranslation },
ja: { translation: jaTranslation },
zh: { translation: zhTranslation },
vi: { translation: viTranslation },
ko: { translation: koTranslation },
},
lng: savedLang, // default language (Thai)
fallbackLng: 'en',
+22 -2
View File
@@ -26,7 +26,9 @@
"continueShopping": "Continue Shopping",
"subtotal": "Subtotal",
"shippingNote": "Shipping and taxes calculated at checkout.",
"proceed": "Proceed to Checkout"
"proceed": "Proceed to Checkout",
"addToCart": "Add to Cart",
"totalPrice": "Total Price"
},
"footer": {
"catchphrase": "Elevate your space with premium fragrances.",
@@ -35,5 +37,23 @@
"followUs": "Follow Us",
"contactUs": "Contact Us",
"rights": "All rights reserved."
},
"common": {
"home": "Home",
"shop": "Shop",
"viewAll": "View All →",
"backToShop": "Back to Shop"
},
"product": {
"notFound": "Product not found",
"premiumCollection": "Premium Collection",
"selectSize": "Select Size",
"details": "Product Details",
"scent": "Scent",
"category": "Category",
"relatedProducts": "Related Products",
"moreRelated": "More {{category}}",
"reviews": "Reviews",
"viewDetails": "View Details →"
}
}
}
+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": "상세 보기 →"
}
}
+22 -2
View File
@@ -26,7 +26,9 @@
"continueShopping": "เลือกซื้อสินค้าต่อ",
"subtotal": "ยอดรวมยอด",
"shippingNote": "ค่าจัดส่งและภาษีจะถูกคำนวณท้ายสุด",
"proceed": "ดำเนินการชำระเงิน"
"proceed": "ดำเนินการชำระเงิน",
"addToCart": "เพิ่มลงตะกร้า",
"totalPrice": "ราคารวม"
},
"footer": {
"catchphrase": "ยกระดับพื้นที่ของคุณด้วยน้ำหอมระดับพรีเมียม",
@@ -35,5 +37,23 @@
"followUs": "ติดตามเรา",
"contactUs": "ติดต่อสอบถาม",
"rights": "สงวนลิขสิทธิ์."
},
"common": {
"home": "หน้าหลัก",
"shop": "ร้านค้า",
"viewAll": "ดูทั้งหมด →",
"backToShop": "กลับไปร้านค้า"
},
"product": {
"notFound": "ไม่พบสินค้า",
"premiumCollection": "พรีเมียมคอลเลกชัน",
"selectSize": "เลือกขนาด",
"details": "รายละเอียดสินค้า",
"scent": "กลิ่น",
"category": "หมวดหมู่",
"relatedProducts": "สินค้าที่เกี่ยวข้อง",
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
"reviews": "รีวิว",
"viewDetails": "ดูรายละเอียด →"
}
}
}
+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 { addToCart } = useCartStore()
const { i18n } = useTranslation()
const { i18n, t } = useTranslation()
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
if (!v) return ''
if (typeof v === 'string') return v
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
}
const displayName = resolveStr(product?.name, i18n.language) || product?.scent?.name || ''
useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
@@ -104,9 +112,9 @@ export default function ProductDetailPage() {
<Navbar categories={categories} />
<div className="pt-40 text-center space-y-4">
<p className="text-4xl">⚠️</p>
<p className="text-spa-stone font-medium">{error ?? 'Product not found'}</p>
<p className="text-spa-stone font-medium">{error ?? t('product.notFound')}</p>
<Link to="/shop" className="text-spa-forest hover:underline text-sm font-bold">
Back to Shop
{t('common.backToShop')}
</Link>
</div>
<Footer categories={categories} />
@@ -123,7 +131,7 @@ export default function ProductDetailPage() {
variantId: currentVariant.id,
quantity: 1,
price: currentVariant.price || product.price || 0,
productName: product.scent.name,
productName: displayName,
variantSize: currentVariant.size,
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
})
@@ -147,15 +155,15 @@ export default function ProductDetailPage() {
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
{/* Breadcrumb */}
<div className="flex items-center flex-wrap gap-2 text-[10px] md:text-xs text-spa-stone/60 uppercase tracking-widest font-semibold mb-6 md:mb-0">
<Link to="/" className="hover:text-spa-forest transition-colors">Home</Link>
<Link to="/" className="hover:text-spa-forest transition-colors">{t('common.home')}</Link>
<span>/</span>
<Link to="/shop" className="hover:text-spa-forest transition-colors">Shop</Link>
<Link to="/shop" className="hover:text-spa-forest transition-colors">{t('common.shop')}</Link>
<span>/</span>
<Link to={`/category/${product.category.id}`} className="hover:text-spa-forest transition-colors">
{product.category.name}
</Link>
<span>/</span>
<span className="text-spa-forest">{product.scent.name}</span>
<span className="text-spa-forest">{displayName}</span>
</div>
{/* Product Detail */}
@@ -177,10 +185,10 @@ export default function ProductDetailPage() {
{product.category.name}
</p>
<h1 className="text-5xl font-bold leading-tight text-pop-black">
{product.scent.name}
{displayName}
</h1>
<p className="text-spa-stone/80 text-sm uppercase tracking-widest font-semibold">
Premium Collection
{t('product.premiumCollection')}
</p>
</div>
@@ -201,7 +209,7 @@ export default function ProductDetailPage() {
{/* Variant Selector */}
<div className="space-y-3">
<p className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">
Select Size
{t('product.selectSize')}
</p>
<div className="flex flex-wrap gap-3">
{product.variants.map((v, i) => (
@@ -229,7 +237,7 @@ export default function ProductDetailPage() {
onClick={handleAddToCart}
className="flex-1 py-4 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300"
>
Add to Cart
{t('cart.addToCart')}
</button>
<button className="bg-white border border-spa-stone/20 text-spa-stone p-4 rounded-lg hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -241,11 +249,11 @@ export default function ProductDetailPage() {
{/* Meta */}
<div className="border-t border-spa-stone/10 pt-6 grid grid-cols-2 gap-4 text-sm text-spa-stone/80">
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Scent</span>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.scent')}</span>
<span className="text-pop-black font-bold">{product.scent.name}</span>
</div>
<div>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">Category</span>
<span className="text-xs uppercase tracking-widest block mb-1 font-semibold text-spa-stone/60">{t('product.category')}</span>
<Link
to={`/category/${product.category.id}`}
className="text-pop-black font-bold hover:text-spa-forest transition-colors"
@@ -260,7 +268,7 @@ export default function ProductDetailPage() {
{/* Full Details Section */}
{resolveTranslatable(product.details, i18n.language) && (
<div className="pt-16 border-t border-spa-stone/10 space-y-6">
<h2 className="text-3xl font-bold text-pop-black">Product Details</h2>
<h2 className="text-3xl font-bold text-pop-black">{t('product.details')}</h2>
<div
className="prose prose-spa prose-lg max-w-4xl product-editorial text-spa-stone"
dangerouslySetInnerHTML={{ __html: resolveTranslatable(product.details, i18n.language) }}
@@ -277,15 +285,15 @@ export default function ProductDetailPage() {
<div className="flex justify-between items-end">
<div>
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-bold opacity-80 mb-2">
More {product.category.name}
{t('product.moreRelated', { category: typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.['en'] })}
</p>
<h2 className="text-3xl font-bold text-pop-black">Related Products</h2>
<h2 className="text-3xl font-bold text-pop-black">{t('product.relatedProducts')}</h2>
</div>
<Link
to={`/category/${product.category.id}`}
className="text-spa-forest text-sm font-bold uppercase tracking-widest hover:underline"
>
View All →
{t('common.viewAll')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
@@ -332,7 +340,7 @@ export default function ProductDetailPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-semibold">
{product.variants && product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
{product.variants && product.variants.length > 1 ? currentVariant?.size : t('cart.totalPrice')}
</span>
<span className="text-xl font-bold text-rose-500">
{formatPrice(currentVariant?.price)}
@@ -342,7 +350,7 @@ export default function ProductDetailPage() {
onClick={handleAddToCart}
className="flex-1 py-3.5 bg-spa-forest text-white font-bold rounded-lg tracking-widest uppercase hover:bg-spa-stone shadow-sm transition-all flex justify-center items-center gap-2"
>
Add to Cart
{t('cart.addToCart')}
</button>
</div>
</div>