feat(cms): implement multi-language support across frontend and admin UI

This commit is contained in:
Your Name committed 2026-06-14 09:00:57 -04:00
1 parent 730e2a7b48
commit eac45dd4a7
62 files changed
+1377 -5116

No files matched your search

@@ -1,6 +1,8 @@
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay } from 'swiper/modules';
import type { AnnouncementBarSectionPayload } from '../types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
// Import Swiper styles
import 'swiper/css';
@@ -10,7 +12,10 @@ interface DynamicAnnouncementBarProps {
}
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
const { slides = [], backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
const slides = (payload.slides || []).map((slide: any) => getLocalizedField(slide, lang));
if (slides.length === 0) return null;
+8 -1
View File
@@ -1,12 +1,19 @@
import { Link } from 'react-router-dom';
import type { BannerSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicBannerProps {
payload: BannerSectionPayload;
}
export default function DynamicBanner({ payload }: DynamicBannerProps) {
const { imageUrl, linkUrl, title, description, buttonText } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const { imageUrl, linkUrl } = payload;
const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang);
const Wrapper = linkUrl ? Link : 'div';
const wrapperProps = linkUrl ? { to: linkUrl } : {};
@@ -1,6 +1,8 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicCategoriesProps {
payload: CategoriesSectionPayload;
@@ -9,7 +11,11 @@ interface DynamicCategoriesProps {
}
export default function DynamicCategories({ payload, categories, loading }: DynamicCategoriesProps) {
const { title, subtitle, categoryIds } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const { categoryIds } = payload;
const title = getLocalizedField(payload.title, lang);
const subtitle = getLocalizedField(payload.subtitle, lang);
// Filter categories if categoryIds list is provided
const displayedCategories = React.useMemo(() => {
@@ -2,6 +2,8 @@ import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination } from 'swiper/modules';
import { Link } from 'react-router-dom';
import type { EditorialCarouselSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
// Import Swiper styles
import 'swiper/css';
@@ -12,7 +14,16 @@ interface DynamicEditorialCarouselProps {
}
export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) {
const { title, slides = [] } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const title = getLocalizedField(payload.title, lang);
const slides = (payload.slides || []).map((slide: any) => ({
...slide,
title: getLocalizedField(slide.title, lang),
description: getLocalizedField(slide.description, lang),
buttonText: getLocalizedField(slide.buttonText, lang),
category: getLocalizedField(slide.category, lang)
}));
if (slides.length === 0) return null;
@@ -1,21 +1,25 @@
import { Link } from 'react-router-dom';
import type { EditorialTileSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicEditorialTileProps {
payload: EditorialTileSectionPayload;
}
export default function DynamicEditorialTile({ payload }: DynamicEditorialTileProps) {
const { i18n } = useTranslation();
const lang = i18n.language;
const {
title,
description,
imageUrl,
imagePosition = 'left',
buttonText,
buttonLink,
backgroundColor = '#FDFDF9', // Default to spa-light
textColor = '#1F3D2D' // Default to spa-forest
} = payload;
const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang);
const renderTitle = (text: string) => {
if (!text) return null;
+13 -9
View File
@@ -1,12 +1,20 @@
import { Link } from 'react-router-dom';
import type { HeroSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicHeroProps {
payload: HeroSectionPayload;
}
export default function DynamicHero({ payload }: DynamicHeroProps) {
const { tagline, title, description, imageUrl, buttonText, buttonLink } = payload;
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { imageUrl, buttonLink } = payload;
const tagline = getLocalizedField(payload.tagline, lang);
const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang);
const renderTitle = (text: string) => {
if (!text) return null;
@@ -44,14 +52,10 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
<div className="relative z-10 text-center space-y-6 px-8 animate-fade-in max-w-4xl w-full mx-auto mt-16 md:mt-0">
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
{tagline || "Vibrant Elegance Collection"}
{tagline || t('hero.defaultTagline')}
</p>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
{renderTitle(title) || (
<>
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
</>
)}
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1>
<p className="text-spa-light/90 max-w-2xl mx-auto text-lg md:text-xl font-light pt-4 pb-8 drop-shadow-md">
{description}
@@ -61,13 +65,13 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
to={buttonLink || "/shop"}
className="px-8 py-4 bg-white text-spa-forest rounded-full font-bold uppercase tracking-widest text-sm hover:bg-spa-sage hover:shadow-xl transition-all duration-300"
>
{buttonText || "Explore Collection"}
{buttonText || t('hero.exploreCollection')}
</Link>
<a
href="#categories"
className="px-8 py-4 text-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
>
Learn More
{t('hero.learnMore')}
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span>
</a>
</div>
+13 -3
View File
@@ -1,9 +1,19 @@
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicHeroArticleProps {
payload: any;
}
export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) {
const { title, subtitle, imageUrl, author, date, category } = payload || {};
const { t, i18n } = useTranslation();
const lang = i18n.language;
const p = payload || {};
const { imageUrl, date } = p;
const title = getLocalizedField(p.title, lang);
const subtitle = getLocalizedField(p.subtitle, lang);
const author = getLocalizedField(p.author, lang);
const category = getLocalizedField(p.category, lang);
return (
<section className="relative w-full min-h-[60vh] md:min-h-[80vh] flex items-center justify-center bg-spa-sage overflow-hidden">
@@ -20,7 +30,7 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
</span>
)}
<h1 className="text-4xl md:text-6xl font-serif mb-6 leading-tight">
{title || 'Scent Guide Article'}
{title || t('newsletter.articleDefaultTitle', 'Scent Guide Article')}
</h1>
{subtitle && (
<p className="text-lg md:text-xl font-light opacity-90 max-w-2xl mx-auto mb-8">
@@ -29,7 +39,7 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
)}
{(author || date) && (
<div className="text-sm font-medium tracking-wide uppercase opacity-80 flex items-center justify-center gap-4">
{author && <span>By {author}</span>}
{author && <span>{t('newsletter.byAuthor', { author })}</span>}
{author && date && <span>•</span>}
{date && <span>{date}</span>}
</div>
+14 -12
View File
@@ -1,19 +1,21 @@
import { useState } from 'react';
import { API_BASE, type NewsletterSectionPayload } from '../types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicNewsletterProps {
payload: NewsletterSectionPayload;
}
export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
const {
title,
description,
buttonText = 'Subscribe',
placeholderText = 'Enter your email address',
showPrivacyCheckbox = true,
privacyText = 'I agree to the privacy policy.'
} = payload;
const { t, i18n } = useTranslation();
const lang = i18n.language;
const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang) || t('newsletter.subscribe');
const placeholderText = getLocalizedField(payload.placeholderText, lang) || t('checkout.emailAddress');
const privacyText = getLocalizedField(payload.privacyText, lang) || 'I agree to the privacy policy.';
const { showPrivacyCheckbox = true } = payload;
const [email, setEmail] = useState('');
const [agreed, setAgreed] = useState(!showPrivacyCheckbox);
@@ -61,7 +63,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
<div className="max-w-3xl mx-auto text-center space-y-8 relative z-10">
<div className="space-y-3">
<h2 className="text-3xl md:text-5xl font-serif font-light tracking-wide text-spa-gold">
{title || "Begin Your Wellness Journey"}
{title || t('newsletter.defaultTitle')}
</h2>
<p className="text-spa-light/80 text-sm md:text-base font-light max-w-xl mx-auto">
{description}
@@ -70,8 +72,8 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
{status === 'success' ? (
<div className="p-6 bg-white/5 border border-spa-gold/30 rounded-2xl animate-pop-in space-y-2">
<h4 className="text-spa-gold font-semibold text-lg">Thank You for Subscribing</h4>
<p className="text-xs text-spa-light/75">You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon.</p>
<h4 className="text-spa-gold font-semibold text-lg">{t('newsletter.thankYou')}</h4>
<p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto">
@@ -90,7 +92,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
disabled={status === 'submitting'}
className="px-8 py-4 bg-white text-spa-forest hover:bg-spa-sage rounded-full font-bold text-sm tracking-widest uppercase transition-all disabled:opacity-50 cursor-pointer"
>
{status === 'submitting' ? 'Submitting...' : buttonText}
{status === 'submitting' ? t('newsletter.subscribing') : buttonText}
</button>
</div>
@@ -4,13 +4,20 @@ import ProductCard from './ProductCard';
import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css/navigation';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicProductCarouselProps {
payload: any;
}
export default function DynamicProductCarousel({ payload }: DynamicProductCarouselProps) {
const { title, description, products = [] } = payload || {};
const { i18n } = useTranslation();
const lang = i18n.language;
const p = payload || {};
const { products = [] } = p;
const title = getLocalizedField(p.title, lang);
const description = getLocalizedField(p.description, lang);
return (
<section className="py-20 bg-spa-sage/30 px-6">
+7 -1
View File
@@ -3,6 +3,8 @@ import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination } from 'swiper/modules';
import ProductCard from './ProductCard';
import type { Product, ProductsSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
// Import Swiper styles
import 'swiper/css';
@@ -13,7 +15,11 @@ interface DynamicProductsProps {
}
export default function DynamicProducts({ payload }: DynamicProductsProps) {
const { title, subtitle, products = [], layout = 'grid', enableQuickShop = false } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const { products = [], layout = 'grid', enableQuickShop = false } = payload;
const title = getLocalizedField(payload.title, lang);
const subtitle = getLocalizedField(payload.subtitle, lang);
return (
<section className="bg-pop-bg py-24 px-6 relative">
@@ -1,9 +1,17 @@
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicQuoteCalloutProps {
payload: any;
}
export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) {
const { quote, author, role } = payload || {};
const { i18n } = useTranslation();
const lang = i18n.language;
const p = payload || {};
const quote = getLocalizedField(p.quote, lang);
const author = getLocalizedField(p.author, lang);
const role = getLocalizedField(p.role, lang);
return (
<section className="py-24 px-6 bg-white">
@@ -1,9 +1,19 @@
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicScentNotesBreakdownProps {
payload: any;
}
export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) {
const { title, description, topNotes, heartNotes, baseNotes } = payload || {};
const { i18n } = useTranslation();
const lang = i18n.language;
const p = payload || {};
const title = getLocalizedField(p.title, lang);
const description = getLocalizedField(p.description, lang);
const topNotes = getLocalizedField(p.topNotes, lang);
const heartNotes = getLocalizedField(p.heartNotes, lang);
const baseNotes = getLocalizedField(p.baseNotes, lang);
return (
<section className="py-20 px-6 bg-spa-light">
+7 -1
View File
@@ -1,11 +1,17 @@
import type { TextSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
interface DynamicTextProps {
payload: TextSectionPayload;
}
export default function DynamicText({ payload }: DynamicTextProps) {
const { title, content, textAlign = 'left' } = payload;
const { i18n } = useTranslation();
const lang = i18n.language;
const { textAlign = 'left' } = payload;
const title = getLocalizedField(payload.title, lang);
const content = getLocalizedField(payload.content, lang);
const alignClass = {
left: 'text-left',
+5 -5
View File
@@ -284,7 +284,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">Signed in as</p>
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
</div>
<Link
@@ -297,7 +297,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
onClick={logoutLiff}
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
>
Logout
{t('nav.logout', 'Logout')}
</button>
</div>
</div>
@@ -307,7 +307,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
onClick={loginLiff}
className="px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
>
LINE Login
{t('nav.login', 'LINE Login')}
</button>
)}
</div>
@@ -503,14 +503,14 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
onClick={() => { logoutLiff(); setMobileOpen(false); }}
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-rose-400 hover:bg-rose-500/10 transition-colors border border-rose-500/20 mt-2"
>
Logout
{t('nav.logout', 'Logout')}
</button>
) : (
<button
onClick={() => { loginLiff(); setMobileOpen(false); }}
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-[#00B900] hover:bg-[#00B900]/10 transition-colors border border-[#00B900]/50 mt-2"
>
Login with LINE
{t('nav.login', 'Login with LINE')}
</button>
)}
</div>
+46 -1
View File
@@ -12,6 +12,9 @@
"findMyScent": "Find My Scent ✨",
"promotions": "Promotions",
"profile": "My Profile",
"signedInAs": "Signed in as",
"logout": "Logout",
"login": "LINE Login",
"search": {
"title": "Search Collection",
"placeholder": "Search scents, categories...",
@@ -55,5 +58,47 @@
"moreRelated": "More {{category}}",
"reviews": "Reviews",
"viewDetails": "View Details →"
},
"checkout": {
"title": "Checkout",
"shippingInfo": "Shipping Information",
"fullName": "Full Name *",
"phoneNumber": "Phone Number *",
"emailAddress": "Email Address *",
"shippingAddress": "Shipping Address *",
"selectProfileAddress": "Select address from profile:",
"confirmOrder": "Confirm Order",
"orderSummary": "Order Summary",
"discountCode": "Discount Code",
"placeholderDiscount": "e.g. SUMMER2026",
"apply": "Apply",
"remove": "Remove",
"usePoints": "Use Loyalty Points",
"pointsAvailable": "{{points}} Points Available",
"applyPoints": "Apply points discount",
"pointsDiscount": "Points (-฿{{points}})",
"subtotal": "Subtotal",
"shippingFee": "Shipping Fee",
"shippingDiscount": "Shipping Discount",
"coupon": "Coupon",
"pointsRedeemed": "Points Redeemed",
"total": "Total",
"invalidCoupon": "Invalid coupon",
"errorValidating": "Error validating coupon",
"failedOrder": "Failed to place order",
"errorOccurred": "An error occurred. Please try again."
},
"hero": {
"defaultTagline": "Vibrant Elegance Collection",
"exploreCollection": "Explore Collection",
"learnMore": "Learn More"
},
"newsletter": {
"defaultTitle": "Begin Your Wellness Journey",
"articleDefaultTitle": "Scent Guide Article",
"byAuthor": "By {{author}}",
"subscribing": "Submitting...",
"thankYou": "Thank You for Subscribing",
"successMessage": "You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon."
}
}
}
+46 -1
View File
@@ -12,6 +12,9 @@
"findMyScent": "ค้นหากลิ่นของคุณ ✨",
"promotions": "โปรโมชั่น",
"profile": "โปรไฟล์ของฉัน",
"signedInAs": "เข้าสู่ระบบในชื่อ",
"logout": "ออกจากระบบ",
"login": "เข้าสู่ระบบด้วย LINE",
"search": {
"title": "ค้นหาสินค้า",
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
@@ -55,5 +58,47 @@
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
"reviews": "รีวิว",
"viewDetails": "ดูรายละเอียด →"
},
"checkout": {
"title": "ชำระเงิน",
"shippingInfo": "ข้อมูลการจัดส่ง",
"fullName": "ชื่อ-นามสกุล *",
"phoneNumber": "เบอร์โทรศัพท์ *",
"emailAddress": "อีเมล *",
"shippingAddress": "ที่อยู่จัดส่ง *",
"selectProfileAddress": "เลือกดึงที่อยู่จัดส่งจากโปรไฟล์:",
"confirmOrder": "ยืนยันการสั่งซื้อ",
"orderSummary": "สรุปคำสั่งซื้อ",
"discountCode": "รหัสส่วนลด",
"placeholderDiscount": "เช่น SUMMER2026",
"apply": "ใช้ส่วนลด",
"remove": "ลบ",
"usePoints": "ใช้คะแนนสะสม",
"pointsAvailable": "มี {{points}} คะแนน",
"applyPoints": "ใช้ส่วนลดด้วยคะแนน",
"pointsDiscount": "คะแนน (-฿{{points}})",
"subtotal": "ยอดรวม",
"shippingFee": "ค่าจัดส่ง",
"shippingDiscount": "ส่วนลดค่าจัดส่ง",
"coupon": "คูปอง",
"pointsRedeemed": "คะแนนที่ใช้",
"total": "ยอดรวมสุทธิ",
"invalidCoupon": "คูปองไม่ถูกต้อง",
"errorValidating": "เกิดข้อผิดพลาดในการตรวจสอบคูปอง",
"failedOrder": "ไม่สามารถทำรายการได้",
"errorOccurred": "เกิดข้อผิดพลาด โปรดลองอีกครั้ง"
},
"hero": {
"defaultTagline": "คอลเลกชันหรูหรามีชีวิตชีวา",
"exploreCollection": "เลือกชมคอลเลกชัน",
"learnMore": "ดูรายละเอียดเพิ่มเติม"
},
"newsletter": {
"defaultTitle": "เริ่มต้นการเดินทางเพื่อสุขภาพของคุณ",
"articleDefaultTitle": "บทความแนะนำกลิ่น",
"byAuthor": "เขียนโดย {{author}}",
"subscribing": "กำลังบันทึก...",
"thankYou": "ขอบคุณที่สมัครรับข่าวสาร",
"successMessage": "คุณได้เข้าร่วมรับข่าวสารจากเราเรียบร้อยแล้ว เราจะส่งสิทธิพิเศษให้คุณเร็วๆ นี้"
}
}
}
+30 -28
View File
@@ -6,8 +6,10 @@ import { API_BASE, formatPrice, type Category, type CustomerAddress } from '../t
import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function CheckoutPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { items, getCartTotal, clearCart } = useCartStore()
const { customer } = useAuthStore()
@@ -203,7 +205,7 @@ export default function CheckoutPage() {
setIsFreeShipping(data.isFreeShipping)
} else {
removeCoupon()
setCouponError(data.error || 'Coupon is no longer valid for this cart')
setCouponError(data.error || t('checkout.invalidCoupon'))
}
})
}
@@ -227,13 +229,13 @@ export default function CheckoutPage() {
setDiscountAmount(data.discountAmount)
setIsFreeShipping(data.isFreeShipping)
} else {
setCouponError(data.error || 'Invalid coupon')
setCouponError(data.error || t('checkout.invalidCoupon'))
setAppliedCoupon(null)
setDiscountAmount(0)
setIsFreeShipping(false)
}
} catch (err) {
setCouponError('Error validating coupon')
setCouponError(t('checkout.errorValidating'))
} finally {
setCouponLoading(false)
}
@@ -300,11 +302,11 @@ export default function CheckoutPage() {
navigate(`/order/${responseData.id}`)
} else {
alert(responseData.error || 'Failed to place order')
alert(responseData.error || t('checkout.failedOrder'))
}
} catch (err) {
console.error(err)
alert('An error occurred. Please try again.')
alert(t('checkout.errorOccurred'))
} finally {
setLoading(false)
}
@@ -315,14 +317,14 @@ export default function CheckoutPage() {
<Navbar categories={categories} />
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 text-pop-black">
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
<h1 className="text-4xl font-bold mb-12">{t('checkout.title')}</h1>
<div className="flex flex-col lg:flex-row gap-12 relative">
{/* Form Section */}
<div className="flex-1 space-y-8 order-2 lg:order-1">
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Shipping Information</h2>
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">{t('checkout.shippingInfo')}</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
@@ -338,7 +340,7 @@ export default function CheckoutPage() {
/>
</div>
<div className="space-y-2">
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Phone Number *</label>
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.phoneNumber')}</label>
<input
id="checkout-phone"
type="tel"
@@ -352,7 +354,7 @@ export default function CheckoutPage() {
/>
</div>
<div className="md:col-span-2 space-y-2">
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Email Address *</label>
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.emailAddress')}</label>
<input
id="checkout-email"
type="email"
@@ -364,7 +366,7 @@ export default function CheckoutPage() {
/>
</div>
<div className="md:col-span-2 space-y-2">
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Shipping Address *</label>
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.shippingAddress')}</label>
<textarea
id="checkout-address"
required
@@ -376,7 +378,7 @@ export default function CheckoutPage() {
/>
{savedAddresses.length > 0 && (
<div className="mt-2 space-y-2">
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">เลือกดึงที่อยู่จัดส่งจากโปรไฟล์:</p>
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">{t('checkout.selectProfileAddress')}</p>
<div className="flex flex-wrap gap-2">
{savedAddresses.map(addr => {
const formatted = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}`
@@ -410,7 +412,7 @@ export default function CheckoutPage() {
>
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
<span>Confirm Order</span>
<span>{t('checkout.confirmOrder')}</span>
</div>
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
</button>
@@ -425,7 +427,7 @@ export default function CheckoutPage() {
onClick={() => setIsSummaryExpanded(!isSummaryExpanded)}
className={`w-full flex items-center justify-between text-xl font-bold uppercase tracking-widest text-spa-forest pb-4 ${isSummaryExpanded ? 'border-b border-spa-stone/10 mb-6' : ''} lg:pointer-events-none lg:border-b lg:border-spa-stone/10 lg:mb-6 transition-all`}
>
<span>Order Summary</span>
<span>{t('checkout.orderSummary')}</span>
<span className="lg:hidden flex items-center gap-3">
{!isSummaryExpanded && <span className="text-sm font-bold text-rose-500 normal-case">{formatPrice(total)}</span>}
<span className="text-spa-stone">
@@ -439,13 +441,13 @@ export default function CheckoutPage() {
<div className="mb-6 pb-6 border-b border-spa-stone/10">
{!appliedCoupon ? (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">Discount Code</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">{t('checkout.discountCode')}</label>
<div className="flex gap-2">
<input
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="e.g. SUMMER2026"
placeholder={t('checkout.placeholderDiscount')}
className="flex-1 bg-spa-light border border-spa-stone/20 rounded-lg px-3 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
/>
<button
@@ -454,7 +456,7 @@ export default function CheckoutPage() {
disabled={couponLoading || !couponCode.trim()}
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
>
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('checkout.apply')}
</button>
</div>
{couponError && <p className="text-xs text-rose-500 mt-1 font-medium">{couponError}</p>}
@@ -467,7 +469,7 @@ export default function CheckoutPage() {
</svg>
<span className="text-sm font-bold tracking-widest text-spa-forest">{appliedCoupon}</span>
</div>
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">{t('checkout.remove')}</button>
</div>
)}
</div>
@@ -477,9 +479,9 @@ export default function CheckoutPage() {
<div className="mb-6 pb-6 border-b border-spa-stone/10 space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80 flex items-center gap-2">
<span className="text-purple-500 text-sm">⭐️</span> Use Loyalty Points
<span className="text-purple-500 text-sm">⭐️</span> {t('checkout.usePoints')}
</label>
<span className="text-xs font-bold text-spa-forest">{customerPoints} Points Available</span>
<span className="text-xs font-bold text-spa-forest">{t('checkout.pointsAvailable', { points: customerPoints })}</span>
</div>
<div className="flex items-center gap-3">
<input
@@ -492,7 +494,7 @@ export default function CheckoutPage() {
}}
className="w-4 h-4 text-spa-forest border-spa-stone/20 rounded focus:ring-spa-forest"
/>
<label htmlFor="use-points" className="text-sm text-pop-black font-medium">Apply points discount</label>
<label htmlFor="use-points" className="text-sm text-pop-black font-medium">{t('checkout.applyPoints')}</label>
</div>
{usePoints && (
<div className="flex items-center gap-2 mt-2">
@@ -504,7 +506,7 @@ export default function CheckoutPage() {
onChange={e => setPointsToUse(Math.min(Number(e.target.value), customerPoints, maxPointsAllowed))}
className="w-24 bg-spa-light border border-spa-stone/20 rounded-lg px-3 py-1.5 text-sm text-pop-black focus:border-spa-forest outline-none"
/>
<span className="text-xs text-spa-stone/80 font-bold uppercase tracking-widest">Points (-฿{pointsToUse})</span>
<span className="text-xs text-spa-stone/80 font-bold uppercase tracking-widest">{t('checkout.pointsDiscount', { points: pointsToUse })}</span>
</div>
)}
</div>
@@ -524,18 +526,18 @@ export default function CheckoutPage() {
<div className="border-t border-spa-stone/10 pt-4 space-y-3 font-medium text-pop-black">
<div className="flex justify-between text-sm text-spa-stone">
<span>Subtotal</span>
<span>{t('checkout.subtotal')}</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-spa-stone">
<span>Shipping Fee</span>
<span>{t('checkout.shippingFee')}</span>
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
<span className={isFreeShipping ? "line-through text-spa-stone/50" : ""}>{formatPrice(shippingFee)}</span>
}
</div>
{isFreeShipping && (
<div className="flex justify-between text-sm text-spa-forest">
<span>Shipping Discount</span>
<span>{t('checkout.shippingDiscount')}</span>
<span>- {formatPrice(shippingFee)}</span>
</div>
)}
@@ -553,18 +555,18 @@ export default function CheckoutPage() {
)}
{discountAmount > 0 && (
<div className="flex justify-between text-sm text-rose-500">
<span>Coupon ({appliedCoupon})</span>
<span>{t('checkout.coupon')} ({appliedCoupon})</span>
<span>- {formatPrice(discountAmount)}</span>
</div>
)}
{pointsDiscount > 0 && (
<div className="flex justify-between text-sm text-purple-500">
<span>Points Redeemed</span>
<span>{t('checkout.pointsRedeemed')}</span>
<span>- {formatPrice(pointsDiscount)}</span>
</div>
)}
<div className="border-t border-spa-stone/10 pt-3 mt-3 flex justify-between text-lg font-bold text-pop-black">
<span>Total</span>
<span>{t('checkout.total')}</span>
<span className="text-rose-500">{formatPrice(total)}</span>
</div>
</div>
@@ -579,7 +581,7 @@ export default function CheckoutPage() {
>
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
<span>Confirm Order</span>
<span>{t('checkout.confirmOrder')}</span>
</div>
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
</button>
+21
View File
@@ -0,0 +1,21 @@
export function getLocalizedField(field: any, currentLang: string): string {
if (field === null || field === undefined) {
return '';
}
if (typeof field === 'string') {
return field;
}
if (typeof field === 'object') {
if (field[currentLang] !== undefined) {
return field[currentLang];
}
if (field['en'] !== undefined) {
return field['en'];
}
const keys = Object.keys(field);
if (keys.length > 0) {
return field[keys[0]];
}
}
return String(field);
}