feat(cms): implement multi-language support across frontend and admin UI
This commit is contained in:
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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "คุณได้เข้าร่วมรับข่าวสารจากเราเรียบร้อยแล้ว เราจะส่งสิทธิพิเศษให้คุณเร็วๆ นี้"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user