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>
|
||||
|
||||
Reference in new issue
Block a user