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>