This commit is contained in:
Your Name committed 2026-06-21 01:03:29 -04:00
1 parent c365add07f
commit c449de2482
28 files changed
+694 -300

No files matched your search

-9
View File
@@ -35,15 +35,6 @@ function App() {
useEffect(() => { useEffect(() => {
const lang = i18n.language; const lang = i18n.language;
document.documentElement.lang = lang; document.documentElement.lang = lang;
document.body.classList.remove('font-sans-ja', 'font-sans-zh', 'font-sans-ko');
if (lang === 'ja') {
document.body.classList.add('font-sans-ja');
} else if (lang === 'zh') {
document.body.classList.add('font-sans-zh');
} else if (lang === 'ko') {
document.body.classList.add('font-sans-ko');
}
}, [i18n.language]); }, [i18n.language]);
return ( return (
@@ -15,13 +15,13 @@ export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementB
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload; const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
const slides = (payload.slides || []).map((slide: any) => getLocalizedField(slide, lang)); const slides = (payload.slides || []).map((slide) => getLocalizedField(slide, lang));
if (slides.length === 0) return null; if (slides.length === 0) return null;
return ( return (
<div <div
className="announcement-bar w-full py-2.5 px-4 text-center text-[10px] sm:text-xs tracking-[0.2em] font-semibold uppercase relative z-30 overflow-hidden" className="announcement-bar relative z-30 w-full overflow-hidden px-4 py-2.5 text-center text-[10px] font-semibold uppercase tracking-[0.2em] sm:text-xs"
style={{ backgroundColor, color: textColor }} style={{ backgroundColor, color: textColor }}
> >
<Swiper <Swiper
+62 -38
View File
@@ -15,47 +15,71 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
const description = getLocalizedField(payload.description, lang); const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang); const buttonText = getLocalizedField(payload.buttonText, lang);
const Wrapper = linkUrl ? Link : 'div';
const wrapperProps = linkUrl ? { to: linkUrl } : {};
return ( return (
<section className="py-16 px-6 bg-white relative overflow-hidden"> <section className="ui-section-band bg-white relative overflow-hidden">
<Wrapper {linkUrl ? (
{...(wrapperProps as any)} <Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
className={`max-w-7xl mx-auto block brutal-box group overflow-hidden relative min-h-[350px] md:min-h-[400px] rounded-3xl cursor-${linkUrl ? 'pointer' : 'default'}`} <div className="absolute inset-0 z-0">
> <img
{/* Background Image */} src={imageUrl}
<div className="absolute inset-0 z-0"> className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
<img alt={title || "Banner"}
src={imageUrl} />
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]" <div className="ui-banner-overlay"></div>
alt={title || "Banner"} </div>
/>
{/* Overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-spa-forest/80 via-spa-forest/40 to-transparent"></div>
</div>
{/* Content */} <div className="ui-banner-content">
<div className="absolute inset-0 z-10 flex flex-col justify-center p-8 md:p-16 max-w-xl text-white space-y-4"> {title && (
{title && ( <h3 className="font-serif text-3xl font-light leading-tight text-spa-gold md:text-5xl">
<h3 className="text-3xl md:text-5xl font-serif font-light text-spa-gold leading-tight"> {title}
{title} </h3>
</h3> )}
)} {description && (
{description && ( <p className="font-light text-base text-spa-light/95 md:text-lg">
<p className="text-spa-light/95 font-light text-base md:text-lg"> {description}
{description} </p>
</p> )}
)} {buttonText && (
{buttonText && linkUrl && ( <div className="pt-4">
<div className="pt-4"> <span className="ui-button-secondary">
<span className="inline-block px-8 py-3 bg-white text-spa-forest font-semibold rounded-full tracking-wider text-sm shadow-lg group-hover:bg-spa-sage transition-all duration-300"> {buttonText}
{buttonText} </span>
</span> </div>
</div> )}
)} </div>
</Link>
) : (
<div className="ui-banner-shell group block cursor-default">
<div className="absolute inset-0 z-0">
<img
src={imageUrl}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
alt={title || "Banner"}
/>
<div className="ui-banner-overlay"></div>
</div>
<div className="ui-banner-content">
{title && (
<h3 className="font-serif text-3xl font-light leading-tight text-spa-gold md:text-5xl">
{title}
</h3>
)}
{description && (
<p className="font-light text-base text-spa-light/95 md:text-lg">
{description}
</p>
)}
{buttonText && (
<div className="pt-4">
<span className="ui-button-secondary">
{buttonText}
</span>
</div>
)}
</div>
</div> </div>
</Wrapper> )}
</section> </section>
); );
} }
+13 -13
View File
@@ -26,41 +26,41 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
}, [categories, categoryIds]); }, [categories, categoryIds]);
return ( return (
<section id="categories" className="py-24 px-6 relative overflow-hidden bg-white"> <section id="categories" className="ui-section-band relative overflow-hidden bg-white">
<div className="max-w-7xl mx-auto space-y-16 relative z-10"> <div className="ui-section-inner space-y-16 relative z-10">
<div className="text-center space-y-4"> <div className="space-y-4 text-center">
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-pop-black"> <h2 className="text-4xl font-bold tracking-tight text-pop-black md:text-5xl">
{title || "Our Collections"} {title || "Our Collections"}
</h2> </h2>
{subtitle && ( {subtitle && (
<p className="text-gray-500 max-w-2xl mx-auto">{subtitle}</p> <p className="ui-section-subtitle">{subtitle}</p>
)} )}
</div> </div>
{loading ? ( {loading ? (
<div className="grid grid-cols-2 md:grid-cols-5 gap-6"> <div className="grid grid-cols-2 gap-6 md:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-80 brutal-box bg-gray-50 animate-pulse" /> <div key={i} className="ui-shell-card h-80 animate-pulse bg-gray-50" />
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-5 gap-6"> <div className="grid grid-cols-2 gap-6 md:grid-cols-5">
{displayedCategories.map((cat) => ( {displayedCategories.map((cat) => (
<Link <Link
key={cat.id} key={cat.id}
to={`/category/${cat.id}`} to={`/category/${cat.id}`}
className="relative h-80 rounded-2xl group overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300" className="ui-shell-card ui-shell-card-hover group relative h-80 overflow-hidden"
> >
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
<img <img
src={cat.imageUrl || getCategoryImage(typeof cat.name === 'string' ? cat.name : (cat.name as any)?.en || '')} src={cat.imageUrl || getCategoryImage(getLocalizedField(cat.name, lang))}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={typeof cat.name === 'string' ? cat.name : (cat.name as any)?.en || ''} alt={getLocalizedField(cat.name, lang)}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/> />
<div className="absolute bottom-6 left-6 right-6 z-20 text-white"> <div className="absolute bottom-6 left-6 right-6 z-20 text-white">
<span className="font-semibold text-xl block mb-1"> <span className="mb-1 block text-xl font-semibold">
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[localStorage.getItem('homaroma_lang') || 'en'] || (cat.name as any)?.en || ''} {getLocalizedField(cat.name, localStorage.getItem('homaroma_lang') || 'en')}
</span> </span>
{cat._count && ( {cat._count && (
<span className="text-sm font-medium text-white/80"> <span className="text-sm font-medium text-white/80">
@@ -17,7 +17,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const title = getLocalizedField(payload.title, lang); const title = getLocalizedField(payload.title, lang);
const slides = (payload.slides || []).map((slide: any) => ({ const slides = (payload.slides || []).map((slide) => ({
...slide, ...slide,
title: getLocalizedField(slide.title, lang), title: getLocalizedField(slide.title, lang),
description: getLocalizedField(slide.description, lang), description: getLocalizedField(slide.description, lang),
@@ -46,17 +46,15 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
}; };
return ( return (
<section className="w-full bg-[#1F3D2D] text-white py-20 px-6 md:px-16 overflow-hidden"> <section className="ui-dark-shell overflow-hidden">
<div className="max-w-7xl mx-auto space-y-12"> <div className="ui-section-inner space-y-12">
{/* Section Header */} <div className="space-y-3 text-center">
<div className="text-center space-y-3"> <h2 className="font-serif text-3xl font-light tracking-wide text-spa-gold md:text-5xl">
<h2 className="text-3xl md:text-5xl font-light tracking-wide font-serif text-spa-gold">
{renderTitle(title) || title} {renderTitle(title) || title}
</h2> </h2>
<div className="w-16 h-[1px] bg-spa-gold mx-auto"></div> <div className="w-16 h-[1px] bg-spa-gold mx-auto"></div>
</div> </div>
{/* Swiper Slider */}
<div className="relative select-none pb-12"> <div className="relative select-none pb-12">
<Swiper <Swiper
modules={[Autoplay, Pagination]} modules={[Autoplay, Pagination]}
@@ -72,8 +70,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
> >
{slides.map((slide, idx) => ( {slides.map((slide, idx) => (
<SwiperSlide key={idx} className="h-auto flex flex-col"> <SwiperSlide key={idx} className="h-auto flex flex-col">
<div className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden flex flex-col h-full group hover:border-spa-gold/30 transition-all duration-300"> <div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-spa-gold/30">
{/* Image wrapper */}
<div className="aspect-[4/3] overflow-hidden relative"> <div className="aspect-[4/3] overflow-hidden relative">
<img <img
src={slide.imageUrl} src={slide.imageUrl}
@@ -83,19 +80,18 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-[#1F3D2D]/60 via-transparent to-transparent"></div> <div className="absolute inset-0 bg-gradient-to-t from-spa-forest/60 via-transparent to-transparent"></div>
</div> </div>
{/* Content details */}
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6"> <div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-spa-gold block"> <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-spa-gold">
{slide.category} {slide.category}
</span> </span>
<h3 className="text-xl font-light font-serif tracking-wide text-spa-light"> <h3 className="font-serif text-xl font-light tracking-wide text-spa-light">
{renderTitle(slide.title) || slide.title} {renderTitle(slide.title) || slide.title}
</h3> </h3>
<p className="text-xs md:text-sm font-light text-spa-light/70 leading-relaxed line-clamp-3"> <p className="line-clamp-3 text-xs font-light leading-relaxed text-spa-light/70 md:text-sm">
{slide.description} {slide.description}
</p> </p>
</div> </div>
@@ -14,8 +14,8 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
imageUrl, imageUrl,
imagePosition = 'left', imagePosition = 'left',
buttonLink, buttonLink,
backgroundColor = '#FDFDF9', // Default to spa-light backgroundColor = '#FDFDF9',
textColor = '#1F3D2D' // Default to spa-forest textColor = '#1F3D2D'
} = payload; } = payload;
const title = getLocalizedField(payload.title, lang); const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang); const description = getLocalizedField(payload.description, lang);
@@ -40,13 +40,12 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
}; };
return ( return (
<section <section
className="w-full overflow-hidden py-16 md:py-24 px-6 md:px-16" className="ui-section-band w-full overflow-hidden"
style={{ backgroundColor, color: textColor }} style={{ backgroundColor, color: textColor }}
> >
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-20 items-center"> <div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20">
{/* Image Column */} <div className={`group relative aspect-square w-full overflow-hidden rounded-3xl shadow-xl ${
<div className={`w-full overflow-hidden rounded-3xl shadow-xl aspect-square relative group ${
imagePosition === 'left' ? 'md:order-1' : 'md:order-2' imagePosition === 'left' ? 'md:order-1' : 'md:order-2'
}`}> }`}>
<img <img
@@ -57,26 +56,24 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
{/* Subtle overlay */}
<div className="absolute inset-0 bg-neutral-900/5 mix-blend-overlay"></div> <div className="absolute inset-0 bg-neutral-900/5 mix-blend-overlay"></div>
</div> </div>
{/* Content Column */} <div className={`flex flex-col justify-center space-y-6 md:space-y-8 ${
<div className={`space-y-6 md:space-y-8 flex flex-col justify-center ${
imagePosition === 'left' ? 'md:order-2 md:pl-8' : 'md:order-1 md:pr-8' imagePosition === 'left' ? 'md:order-2 md:pl-8' : 'md:order-1 md:pr-8'
}`}> }`}>
<h2 className="text-3xl md:text-5xl font-light leading-tight tracking-wide font-serif"> <h2 className="font-serif text-3xl font-light leading-tight tracking-wide md:text-5xl">
{renderTitle(title) || title} {renderTitle(title) || title}
</h2> </h2>
<div className="w-12 h-[1px] bg-spa-gold"></div> <div className="w-12 h-[1px] bg-spa-gold"></div>
<p className="text-sm md:text-base font-light leading-relaxed opacity-90 whitespace-pre-line"> <p className="whitespace-pre-line text-sm font-light leading-relaxed opacity-90 md:text-base">
{description} {description}
</p> </p>
{buttonText && buttonLink && ( {buttonText && buttonLink && (
<div className="pt-4"> <div className="pt-4">
<Link <Link
to={buttonLink} to={buttonLink}
className="inline-block px-8 py-3.5 border border-current rounded-full font-bold uppercase tracking-widest text-xs hover:bg-spa-gold hover:border-spa-gold hover:text-white transition-all duration-300" className="ui-chip border-current px-8 py-3.5 hover:bg-spa-gold hover:border-spa-gold hover:text-white"
> >
{buttonText} {buttonText}
</Link> </Link>
+8 -9
View File
@@ -35,7 +35,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
}; };
return ( return (
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-night-900"> <section className="ui-hero-shell">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img <img
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`} src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
@@ -45,31 +45,30 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
{/* Dark gradient overlay for premium vibe */} <div className="ui-hero-overlay" />
<div className="absolute inset-0 bg-neutral-900/40"></div> <div className="ui-hero-overlay-strong" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30"></div>
</div> </div>
<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"> <div className="ui-hero-content animate-fade-in">
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md"> <p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
{tagline || t('hero.defaultTagline')} {tagline || t('hero.defaultTagline')}
</p> </p>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif"> <h1 className="ui-hero-title">
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))} {renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
</h1> </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"> <p className="ui-hero-copy">
{description} {description}
</p> </p>
<div className="flex flex-col sm:flex-row gap-6 items-center justify-center"> <div className="flex flex-col sm:flex-row gap-6 items-center justify-center">
<Link <Link
to={buttonLink || "/shop"} 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" className="ui-button-secondary"
> >
{buttonText || t('hero.exploreCollection')} {buttonText || t('hero.exploreCollection')}
</Link> </Link>
<a <a
href="#categories" 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" className="ui-dark-link group"
> >
{t('hero.learnMore')} {t('hero.learnMore')}
<span className="group-hover:translate-x-1 transition-transform">&rarr;</span> <span className="group-hover:translate-x-1 transition-transform">&rarr;</span>
+13 -8
View File
@@ -1,8 +1,13 @@
import type { HeroArticlePayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
interface DynamicHeroArticleProps { interface DynamicHeroArticleProps {
payload: any; payload: HeroArticlePayload & {
imageUrl?: string;
date?: string;
category?: string | { [key: string]: string };
};
} }
export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) { export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) {
@@ -16,29 +21,29 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
const category = getLocalizedField(p.category, lang); const category = getLocalizedField(p.category, lang);
return ( return (
<section className="relative w-full min-h-[60vh] md:min-h-[80vh] flex items-center justify-center bg-spa-sage overflow-hidden"> <section className="ui-hero-shell min-h-[60vh] md:min-h-[80vh]">
{imageUrl && ( {imageUrl && (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img src={imageUrl} alt={title || 'Article Hero'} className="w-full h-full object-cover" /> <img src={imageUrl} alt={title || 'Article Hero'} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40" /> <div className="ui-hero-overlay" />
</div> </div>
)} )}
<div className="relative z-10 text-center max-w-4xl px-6 text-white"> <div className="ui-hero-content text-white">
{category && ( {category && (
<span className="text-sm uppercase tracking-widest font-bold mb-4 block text-spa-gold"> <span className="mb-4 block text-sm font-bold uppercase tracking-widest text-spa-gold">
{category} {category}
</span> </span>
)} )}
<h1 className="text-4xl md:text-6xl font-serif mb-6 leading-tight"> <h1 className="ui-hero-title mb-6">
{title || t('newsletter.articleDefaultTitle', 'Scent Guide Article')} {title || t('newsletter.articleDefaultTitle', 'Scent Guide Article')}
</h1> </h1>
{subtitle && ( {subtitle && (
<p className="text-lg md:text-xl font-light opacity-90 max-w-2xl mx-auto mb-8"> <p className="mx-auto mb-8 max-w-2xl text-lg font-light opacity-90 md:text-xl">
{subtitle} {subtitle}
</p> </p>
)} )}
{(author || date) && ( {(author || date) && (
<div className="text-sm font-medium tracking-wide uppercase opacity-80 flex items-center justify-center gap-4"> <div className="flex items-center justify-center gap-4 text-sm font-medium uppercase tracking-wide opacity-80">
{author && <span>{t('newsletter.byAuthor', { author })}</span>} {author && <span>{t('newsletter.byAuthor', { author })}</span>}
{author && date && <span>•</span>} {author && date && <span>•</span>}
{date && <span>{date}</span>} {date && <span>{date}</span>}
@@ -53,19 +53,18 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
}; };
return ( return (
<section className="py-20 px-6 bg-spa-forest text-white relative overflow-hidden"> <section className="ui-dark-shell relative overflow-hidden">
{/* Background decorations for luxury aesthetic */}
<div className="absolute inset-0 opacity-10 pointer-events-none"> <div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute top-10 left-10 w-96 h-96 rounded-full bg-spa-gold filter blur-3xl"></div> <div className="absolute top-10 left-10 w-96 h-96 rounded-full bg-spa-gold filter blur-3xl"></div>
<div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-spa-sage filter blur-3xl"></div> <div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-spa-sage filter blur-3xl"></div>
</div> </div>
<div className="max-w-3xl mx-auto text-center space-y-8 relative z-10"> <div className="ui-section-inner relative z-10 space-y-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-3xl md:text-5xl font-serif font-light tracking-wide text-spa-gold"> <h2 className="font-serif text-3xl font-light tracking-wide text-spa-gold md:text-5xl">
{title || t('newsletter.defaultTitle')} {title || t('newsletter.defaultTitle')}
</h2> </h2>
<p className="text-spa-light/80 text-sm md:text-base font-light max-w-xl mx-auto"> <p className="mx-auto max-w-xl text-sm font-light text-spa-light/80 md:text-base">
{description} {description}
</p> </p>
</div> </div>
@@ -76,7 +75,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
<p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p> <p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto"> <form onSubmit={handleSubmit} className="mx-auto max-w-lg space-y-4">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<input <input
type="email" type="email"
@@ -85,12 +84,12 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
placeholder={placeholderText} placeholder={placeholderText}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="flex-1 px-6 py-4 bg-white/5 border border-white/10 rounded-full text-white placeholder-white/40 focus:outline-none focus:border-spa-gold text-sm transition-all" className="ui-input-shell flex-1"
/> />
<button <button
type="submit" type="submit"
disabled={status === 'submitting'} 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" className="ui-button-secondary disabled:opacity-50 cursor-pointer"
> >
{status === 'submitting' ? t('newsletter.subscribing') : buttonText} {status === 'submitting' ? t('newsletter.subscribing') : buttonText}
</button> </button>
@@ -1,4 +1,4 @@
import type { PageSection, Category } from '@homaroma/types'; import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload } from '@homaroma/types';
import DynamicHero from './DynamicHero'; import DynamicHero from './DynamicHero';
import DynamicCategories from './DynamicCategories'; import DynamicCategories from './DynamicCategories';
import DynamicProducts from './DynamicProducts'; import DynamicProducts from './DynamicProducts';
@@ -73,11 +73,11 @@ export default function DynamicPageRenderer({
case 'PRODUCT_CAROUSEL': case 'PRODUCT_CAROUSEL':
return <DynamicProductCarousel key={section.id} payload={section.payload} />; return <DynamicProductCarousel key={section.id} payload={section.payload} />;
case 'ACCORDION': case 'ACCORDION':
return <AccordionSection key={section.id} payload={section.payload as any} />; return <AccordionSection key={section.id} payload={section.payload as AccordionSectionPayload & { title?: string }} />;
case 'CONTACT_INFO': case 'CONTACT_INFO':
return <ContactInfoSection key={section.id} payload={section.payload as any} />; return <ContactInfoSection key={section.id} payload={section.payload as ContactInfoSectionPayload & { title?: string }} />;
case 'CONTACT_FORM': case 'CONTACT_FORM':
return <ContactFormSection key={section.id} payload={section.payload as any} />; return <ContactFormSection key={section.id} payload={section.payload as ContactFormSectionPayload} />;
default: default:
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`); console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
return null; return null;
+5 -5
View File
@@ -24,11 +24,11 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
const categoryLookup = new Map(categories.map((category) => [category.id, category])); const categoryLookup = new Map(categories.map((category) => [category.id, category]));
return ( return (
<section className="bg-pop-bg py-24 px-6 relative"> <section className="ui-section-band relative bg-pop-bg">
<div className="max-w-7xl mx-auto space-y-12 relative z-10"> <div className="ui-section-inner relative z-10 space-y-12">
<div className="flex flex-col md:flex-row justify-between items-end gap-6 border-b border-gray-200 pb-6"> <div className="flex items-end justify-between gap-6 border-b border-gray-200 pb-6 md:flex-row">
<div> <div>
<h2 className="text-4xl md:text-5xl font-bold text-pop-black"> <h2 className="text-4xl font-bold text-pop-black md:text-5xl">
{title || "Featured Picks"} {title || "Featured Picks"}
</h2> </h2>
{subtitle && ( {subtitle && (
@@ -45,7 +45,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
</div> </div>
{products.length === 0 ? ( {products.length === 0 ? (
<div className="text-center py-12 text-gray-500"> <div className="py-12 text-center text-gray-500">
No products found in this section No products found in this section
</div> </div>
) : layout === 'carousel' ? ( ) : layout === 'carousel' ? (
@@ -1,25 +1,25 @@
import type { QuoteCalloutPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
interface DynamicQuoteCalloutProps { interface DynamicQuoteCalloutProps {
payload: any; payload: QuoteCalloutPayload & { role?: string };
} }
export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) { export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const p = payload || {}; const quote = getLocalizedField(payload.quote, lang);
const quote = getLocalizedField(p.quote, lang); const author = getLocalizedField(payload.author, lang);
const author = getLocalizedField(p.author, lang); const role = getLocalizedField(payload.role, lang);
const role = getLocalizedField(p.role, lang);
return ( return (
<section className="py-24 px-6 bg-white"> <section className="ui-section-band bg-white">
<div className="max-w-4xl mx-auto text-center"> <div className="ui-section-inner text-center">
<svg className="w-12 h-12 mx-auto text-spa-gold/40 mb-8" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-12 h-12 mx-auto text-spa-gold/40 mb-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" /> <path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg> </svg>
<blockquote className="text-2xl md:text-4xl font-serif text-spa-forest italic leading-relaxed mb-8"> <blockquote className="mb-8 font-serif text-2xl italic leading-relaxed text-spa-forest md:text-4xl">
"{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}" "{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}"
</blockquote> </blockquote>
{(author || role) && ( {(author || role) && (
@@ -1,17 +1,17 @@
import type { ScentNotesBreakdownPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; import { getLocalizedField } from '../utils/localization';
interface DynamicScentNotesBreakdownProps { interface DynamicScentNotesBreakdownProps {
payload: any; payload: ScentNotesBreakdownPayload;
} }
export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) { export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const p = payload || {}; const title = getLocalizedField(payload.title, lang);
const title = getLocalizedField(p.title, lang); const description = getLocalizedField(payload.description, lang);
const description = getLocalizedField(p.description, lang); const notes = (value: ScentNotesBreakdownPayload['topNotes'] | ScentNotesBreakdownPayload['middleNotes'] | ScentNotesBreakdownPayload['baseNotes']) => Array.isArray(value)
const notes = (value: any) => Array.isArray(value)
? value.map((item) => getLocalizedField(item, lang)).filter(Boolean).join(', ') ? value.map((item) => getLocalizedField(item, lang)).filter(Boolean).join(', ')
: getLocalizedField(value, lang); : getLocalizedField(value, lang);
const labels = { const labels = {
@@ -23,39 +23,39 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' } ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' }
} as const; } as const;
const labelSet = labels[lang as keyof typeof labels] ?? labels.en; const labelSet = labels[lang as keyof typeof labels] ?? labels.en;
const topNotes = notes(p.topNotes); const topNotes = notes(payload.topNotes);
const heartNotes = notes(p.heartNotes ?? p.middleNotes); const heartNotes = notes(payload.middleNotes);
const baseNotes = notes(p.baseNotes); const baseNotes = notes(payload.baseNotes);
return ( return (
<section className="py-20 px-6 bg-spa-light"> <section className="ui-section-band bg-spa-light">
<div className="max-w-5xl mx-auto text-center"> <div className="ui-section-inner text-center">
<h2 className="text-3xl md:text-5xl font-serif text-spa-forest mb-6"> <h2 className="mb-6 font-serif text-3xl text-spa-forest md:text-5xl">
{title || 'Scent Notes'} {title || 'Scent Notes'}
</h2> </h2>
{description && ( {description && (
<p className="text-lg text-spa-stone/80 max-w-2xl mx-auto mb-16"> <p className="ui-section-subtitle mb-16 text-lg text-spa-stone/80">
{description} {description}
</p> </p>
)} )}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 gap-8 md:grid-cols-3">
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10"> <div className="ui-shell-card p-8">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.top}</h3> <h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.top}</h3>
<p className="text-spa-stone font-light leading-relaxed"> <p className="text-spa-stone font-light leading-relaxed">
{topNotes || 'The initial, lighter smells of the fragrance.'} {topNotes || 'The initial, lighter smells of the fragrance.'}
</p> </p>
</div> </div>
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10"> <div className="ui-shell-card p-8">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.heart}</h3> <h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.heart}</h3>
<p className="text-spa-stone font-light leading-relaxed"> <p className="text-spa-stone font-light leading-relaxed">
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'} {heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
</p> </p>
</div> </div>
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10"> <div className="ui-shell-card p-8">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.base}</h3> <h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.base}</h3>
<p className="text-spa-stone font-light leading-relaxed"> <p className="text-spa-stone font-light leading-relaxed">
{baseNotes || 'The deepest, richest scents that linger the longest.'} {baseNotes || 'The deepest, richest scents that linger the longest.'}
</p> </p>
+4 -4
View File
@@ -20,15 +20,15 @@ export default function DynamicText({ payload }: DynamicTextProps) {
}[textAlign] || 'text-left'; }[textAlign] || 'text-left';
return ( return (
<section className="py-16 px-6 bg-white relative"> <section className="ui-section-band bg-white relative">
<div className={`max-w-4xl mx-auto space-y-6 ${alignClass}`}> <div className={`ui-section-inner space-y-6 ${alignClass}`}>
{title && ( {title && (
<h2 className="text-3xl md:text-4xl font-serif text-spa-forest font-semibold"> <h2 className="font-serif text-3xl font-semibold text-spa-forest md:text-4xl">
{title} {title}
</h2> </h2>
)} )}
<div <div
className="text-gray-600 leading-relaxed text-base md:text-lg font-light whitespace-pre-wrap" className="whitespace-pre-wrap text-base font-light leading-relaxed text-gray-600 md:text-lg"
dangerouslySetInnerHTML={{ __html: content }} dangerouslySetInnerHTML={{ __html: content }}
/> />
</div> </div>
+25 -15
View File
@@ -2,10 +2,20 @@ import { Link } from 'react-router-dom'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types' import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLocalizedField } from '../utils/localization'
interface FooterProps { interface FooterProps {
categories?: Category[] categories?: Category[]
} }
type MenuTranslation = {
langCode?: string
label?: string
}
type MenuItemWithTranslations = MenuItem & {
translations?: MenuTranslation[]
}
export default function Footer({ categories = [] }: FooterProps) { export default function Footer({ categories = [] }: FooterProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([]) const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
@@ -13,10 +23,10 @@ export default function Footer({ categories = [] }: FooterProps) {
const [settings, setSettings] = useState<StoreSettings | null>(null) const [settings, setSettings] = useState<StoreSettings | null>(null)
const brandName = settings?.brandName || '' const brandName = settings?.brandName || ''
const translateMenuLabel = (item: MenuItem) => { const translateMenuLabel = (item: MenuItemWithTranslations) => {
const lang = localStorage.getItem('homaroma_lang') || 'en' const lang = localStorage.getItem('homaroma_lang') || 'en'
if (lang !== 'en' && item.translations && item.translations.length > 0) { if (lang !== 'en' && item.translations && item.translations.length > 0) {
const trans = item.translations.find((t: any) => t.langCode === lang) const trans = item.translations.find((translation) => translation.langCode === lang)
if (trans && trans.label) { if (trans && trans.label) {
return trans.label return trans.label
} }
@@ -41,14 +51,14 @@ export default function Footer({ categories = [] }: FooterProps) {
fetch(`${API_BASE}/menus/footer-explore`) fetch(`${API_BASE}/menus/footer-explore`)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data && data.items) setExploreMenu(data.items.sort((a: any, b: any) => a.order - b.order)) if (data && data.items) setExploreMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order))
}) })
.catch(console.error) .catch(console.error)
fetch(`${API_BASE}/menus/footer-support`) fetch(`${API_BASE}/menus/footer-support`)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data && data.items) setSupportMenu(data.items.sort((a: any, b: any) => a.order - b.order)) if (data && data.items) setSupportMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order))
}) })
.catch(console.error) .catch(console.error)
@@ -61,31 +71,31 @@ export default function Footer({ categories = [] }: FooterProps) {
}, []) }, [])
return ( return (
<footer className="bg-black py-20 px-6 border-t border-white/5"> <footer className="bg-night-900 px-6 py-20 border-t border-white/5">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12"> <div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4">
<div className="space-y-6"> <div className="space-y-6">
<div className="text-2xl font-bold tracking-widest"> <div className="text-2xl font-bold tracking-widest">
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName} {settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
</div> </div>
<p className="text-slate-500 text-sm leading-relaxed"> <p className="text-sm leading-relaxed text-slate-500">
{settings?.footerDescription || t('footer.catchphrase')} {settings?.footerDescription || t('footer.catchphrase')}
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? ( {settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
<> <>
{settings.facebookUrl && ( {settings.facebookUrl && (
<a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">FB</a> <a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-white/10 bg-white/5 text-[10px] text-white hover:bg-spa-gold hover:text-night-900">FB</a>
)} )}
{settings.instagramUrl && ( {settings.instagramUrl && (
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">IG</a> <a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-white/10 bg-white/5 text-[10px] text-white hover:bg-spa-gold hover:text-night-900">IG</a>
)} )}
{settings.tiktokUrl && ( {settings.tiktokUrl && (
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300">TT</a> <a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-white/10 bg-white/5 text-[10px] text-white hover:bg-spa-gold hover:text-night-900">TT</a>
)} )}
</> </>
) : ( ) : (
['IG', 'FB', 'TW', 'YT'].map((s) => ( ['IG', 'FB', 'TW', 'YT'].map((s) => (
<a key={s} href="#" className="glass w-9 h-9 rounded-full flex items-center justify-center text-[10px] font-bold hover:bg-cyan-glow hover:text-night-900 transition-all duration-300"> <a key={s} href="#" className="ui-chip h-9 w-9 border-white/10 bg-white/5 text-[10px] text-white hover:bg-spa-gold hover:text-night-900">
{s} {s}
</a> </a>
)) ))
@@ -94,7 +104,7 @@ export default function Footer({ categories = [] }: FooterProps) {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4> <h4 className="text-xs font-bold uppercase tracking-widest text-white">{t('footer.explore')}</h4>
<div className="flex flex-col space-y-2 text-sm text-slate-400"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
{exploreMenu.length > 0 ? ( {exploreMenu.length > 0 ? (
exploreMenu.map(item => ( exploreMenu.map(item => (
@@ -114,18 +124,18 @@ export default function Footer({ categories = [] }: FooterProps) {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('nav.collections')}</h4> <h4 className="text-xs font-bold uppercase tracking-widest text-white">{t('nav.collections')}</h4>
<div className="flex flex-col space-y-2 text-sm text-slate-400"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
{categories.map((c) => ( {categories.map((c) => (
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors"> <Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors">
{typeof c.name === 'string' ? c.name : (c.name as any)?.[localStorage.getItem('homaroma_lang') || 'en'] || (c.name as any)?.en || ''} {getLocalizedField(c.name, localStorage.getItem('homaroma_lang') || 'en')}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white"> <h4 className="text-xs font-bold uppercase tracking-widest text-white">
{settings?.footerSupportHeading || t('footer.support', 'Support & Legal')} {settings?.footerSupportHeading || t('footer.support', 'Support & Legal')}
</h4> </h4>
<div className="flex flex-col space-y-2 text-sm text-slate-400"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
+6 -7
View File
@@ -123,7 +123,6 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
} }
}, [topOffsetClassName]) }, [topOffsetClassName])
useEffect(() => { setMobileOpen(false) }, [navigate])
useEffect(() => { useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : '' document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
@@ -152,16 +151,16 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
<> <>
<nav <nav
ref={navRef} ref={navRef}
className={`fixed ${finalTopClassName} w-full z-50 transition-all duration-300 border-b border-black/5 ${ className={`fixed ${finalTopClassName} z-50 w-full border-b border-black/5 transition-all duration-300 ${
scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none' scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none'
} bg-white/80 backdrop-blur-xl md:bg-[#F9F8F5] md:backdrop-blur-none px-4 md:px-6 py-3.5 md:py-4`} } bg-white/80 px-4 py-3.5 backdrop-blur-xl md:bg-[#F9F8F5] md:px-6 md:py-4 md:backdrop-blur-none`}
> >
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */} {/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
<div className="w-full flex items-center justify-between gap-4"> <div className="w-full flex items-center justify-between gap-4">
{/* Left: Logo */} {/* Left: Logo */}
<div className="flex items-center shrink-0"> <div className="flex items-center shrink-0">
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-spa-forest uppercase italic shrink-0"> <Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-spa-forest uppercase shrink-0 md:text-2xl">
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName} {logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
</Link> </Link>
</div> </div>
@@ -178,7 +177,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
{/* Search — desktop & mobile */} {/* Search — desktop & mobile */}
<button <button
onClick={() => { setSearchOpen(true); setMobileOpen(false); }} onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
className="p-1.5 md:p-2 rounded-full text-spa-stone hover:bg-spa-sage transition-all group" className="group rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
aria-label="Search" aria-label="Search"
> >
<svg className="w-4 h-4 group-hover:text-spa-forest transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 group-hover:text-spa-forest transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -189,7 +188,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
<Link <Link
to="/wishlist" to="/wishlist"
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className="relative p-1.5 md:p-2 rounded-full text-spa-stone hover:bg-spa-sage transition-all group" className="group relative rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
aria-label="Wishlist" aria-label="Wishlist"
> >
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" /> <Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
@@ -202,7 +201,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
<button <button
onClick={() => { toggleCart(true); setMobileOpen(false); }} onClick={() => { toggleCart(true); setMobileOpen(false); }}
className="relative p-1.5 md:p-2 rounded-full text-spa-stone hover:bg-spa-sage transition-all group" className="group relative rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
aria-label="Cart" aria-label="Cart"
> >
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" /> <ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
+15 -21
View File
@@ -46,10 +46,9 @@ export default function ProductCard({ product, enableQuickShop = false, category
return ( return (
<div <div
onClick={handleCardClick} onClick={handleCardClick}
className="bg-white shadow-sm border border-spa-stone/10 rounded-2xl overflow-hidden flex flex-col group p-2 transition-all duration-300 hover:shadow-lg hover:border-spa-forest/30 cursor-pointer" className="ui-shell-card ui-shell-card-hover group flex cursor-pointer flex-col overflow-hidden p-2"
> >
{/* Image Area */} <div className="relative h-48 overflow-hidden rounded-xl bg-spa-sage/20 md:h-64">
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
<img <img
src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)} src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
@@ -58,15 +57,13 @@ export default function ProductCard({ product, enableQuickShop = false, category
(e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).style.display = 'none';
}} }}
/> />
{/* Category badge */} <div className="ui-chip absolute left-3 top-3 border-white/20 bg-white/90 px-2.5 py-1 text-[10px] text-spa-forest shadow-sm backdrop-blur-sm">
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm">
{displayCategoryName} {displayCategoryName}
</div> </div>
{/* Wishlist Button */}
<button <button
type="button" type="button"
onClick={handleWishlistClick} onClick={handleWishlistClick}
className="absolute top-3 right-3 p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:scale-110 transition-transform z-10 cursor-pointer" className="absolute right-3 top-3 z-10 rounded-full bg-white/90 p-2 shadow-sm backdrop-blur-sm transition-transform hover:scale-110 cursor-pointer"
> >
<Heart <Heart
className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`} className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`}
@@ -74,20 +71,18 @@ export default function ProductCard({ product, enableQuickShop = false, category
</button> </button>
</div> </div>
{/* Content Area */} <div className="flex flex-1 flex-col p-4">
<div className="p-4 flex flex-col flex-1">
<div className="mb-2"> <div className="mb-2">
<h3 className="text-base md:text-lg font-bold text-pop-black leading-tight line-clamp-1 group-hover:text-spa-forest transition-colors"> <h3 className="line-clamp-1 text-base font-bold leading-tight text-pop-black transition-colors group-hover:text-spa-forest md:text-lg">
{displayName} {displayName}
</h3> </h3>
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold"> <p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-spa-stone/60 md:text-xs">
{displayCategoryName} {displayCategoryName}
</p> </p>
</div> </div>
{/* Quick Shop Variant Swatches */}
{enableQuickShop && product.variants && product.variants.length > 0 && ( {enableQuickShop && product.variants && product.variants.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2 mb-1"> <div className="mb-1 mt-2 flex flex-wrap gap-1.5">
{product.variants.map((variant) => { {product.variants.map((variant) => {
const isSelected = selectedVariant?.id === variant.id; const isSelected = selectedVariant?.id === variant.id;
return ( return (
@@ -99,10 +94,10 @@ export default function ProductCard({ product, enableQuickShop = false, category
e.stopPropagation(); e.stopPropagation();
setSelectedVariant(variant); setSelectedVariant(variant);
}} }}
className={`px-3 py-1 rounded-full text-[10px] font-bold tracking-wider uppercase border transition-all cursor-pointer ${ className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
isSelected isSelected
? 'bg-spa-forest border-spa-forest text-white shadow-sm' ? 'border-spa-forest bg-spa-forest text-white shadow-sm'
: 'bg-white border-spa-stone/10 text-spa-stone hover:border-spa-forest/30' : 'border-spa-stone/10 bg-white text-spa-stone hover:border-spa-forest/30'
}`} }`}
> >
{variant.size} {variant.size}
@@ -112,14 +107,13 @@ export default function ProductCard({ product, enableQuickShop = false, category
</div> </div>
)} )}
{/* Bottom Area */} <div className="mt-auto flex flex-col gap-2 pt-3">
<div className="mt-auto pt-3 flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-rose-500 font-black text-sm md:text-lg"> <span className="text-sm font-black text-rose-500 md:text-lg">
{formatPrice(selectedVariant?.price ?? product.variants[0]?.price)} {formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
</span> </span>
{!enableQuickShop && ( {!enableQuickShop && (
<span className="text-[10px] md:text-xs font-bold text-spa-forest uppercase tracking-widest flex items-center gap-1 group-hover:translate-x-1 transition-transform"> <span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-spa-forest transition-transform group-hover:translate-x-1 md:text-xs">
{t('product.viewDetails')} {t('product.viewDetails')}
</span> </span>
)} )}
@@ -141,7 +135,7 @@ export default function ProductCard({ product, enableQuickShop = false, category
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName) productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
}); });
}} }}
className="w-full py-2.5 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-xl font-bold text-xs uppercase tracking-widest transition-all shadow-sm hover:shadow flex items-center justify-center gap-1.5 cursor-pointer" className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
> >
<ShoppingBag className="w-3.5 h-3.5" /> <ShoppingBag className="w-3.5 h-3.5" />
{t('cart.addToCart')} {t('cart.addToCart')}
@@ -5,30 +5,30 @@ import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../../utils/localization'; import { getLocalizedField } from '../../utils/localization';
interface AccordionSectionProps { interface AccordionSectionProps {
payload: AccordionSectionPayload; payload: AccordionSectionPayload & { title?: string };
} }
export default function AccordionSection({ payload }: AccordionSectionProps) { export default function AccordionSection({ payload }: AccordionSectionProps) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const [openIndex, setOpenIndex] = useState<string | null>(null); const [openIndex, setOpenIndex] = useState<string | null>(null);
const title = getLocalizedField((payload as any)?.title, lang); const title = getLocalizedField(payload.title, lang);
const toggleAccordion = (index: string) => { const toggleAccordion = (index: string) => {
setOpenIndex(openIndex === index ? null : index); setOpenIndex(openIndex === index ? null : index);
}; };
return ( return (
<section className="w-full py-16 px-4 md:px-8 bg-spa-sand/20"> <section className="ui-section-band w-full bg-spa-sand/20 px-4 md:px-8">
<div className="max-w-4xl mx-auto"> <div className="ui-section-inner">
{title && ( {title && (
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-10 text-center"> <h2 className="mb-10 text-center font-serif text-3xl text-spa-charcoal md:text-4xl">
{title} {title}
</h2> </h2>
)} )}
{payload.categories.map((category, catIndex) => ( {payload.categories.map((category, catIndex) => (
<div key={catIndex} className="mb-12 last:mb-0"> <div key={catIndex} className="mb-12 last:mb-0">
<h3 className="text-2xl font-serif text-spa-charcoal mb-6 flex items-center gap-3"> <h3 className="mb-6 flex items-center gap-3 font-serif text-2xl text-spa-charcoal">
{category.icon && <span className="text-spa-sage">{category.icon}</span>} {category.icon && <span className="text-spa-sage">{category.icon}</span>}
{getLocalizedField(category.name, lang)} {getLocalizedField(category.name, lang)}
</h3> </h3>
@@ -39,17 +39,17 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
return ( return (
<div <div
key={qIndex} key={qIndex}
className="border border-spa-sage/30 rounded-xl bg-white overflow-hidden transition-all duration-300" className="ui-shell-card overflow-hidden transition-all duration-300"
> >
<button <button
type="button" type="button"
className="w-full flex items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-spa-sage/50" className="flex w-full items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-spa-sage/50"
onClick={() => toggleAccordion(indexKey)} onClick={() => toggleAccordion(indexKey)}
> >
<span className="text-lg font-medium text-spa-charcoal pr-4"> <span className="pr-4 text-lg font-medium text-spa-charcoal">
{getLocalizedField(item.q, lang)} {getLocalizedField(item.q, lang)}
</span> </span>
<span className="text-spa-sage flex-shrink-0"> <span className="flex-shrink-0 text-spa-sage">
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />} {isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</span> </span>
</button> </button>
@@ -5,14 +5,13 @@ import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../../utils/localization'; import { getLocalizedField } from '../../utils/localization';
interface ContactInfoSectionProps { interface ContactInfoSectionProps {
payload: ContactInfoSectionPayload; payload: ContactInfoSectionPayload & { title?: string };
} }
export default function ContactInfoSection({ payload }: ContactInfoSectionProps) { export default function ContactInfoSection({ payload }: ContactInfoSectionProps) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const p = payload || {}; const title = getLocalizedField(payload.title, lang);
const title = getLocalizedField((p as any).title, lang);
const labels = { const labels = {
th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', hours: 'เวลาทำการ' }, th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', hours: 'เวลาทำการ' },
en: { address: 'Address', email: 'Email', line: 'Line', hours: 'Business Hours' }, en: { address: 'Address', email: 'Email', line: 'Line', hours: 'Business Hours' },
@@ -24,32 +23,32 @@ export default function ContactInfoSection({ payload }: ContactInfoSectionProps)
const labelSet = labels[lang as keyof typeof labels] ?? labels.en; const labelSet = labels[lang as keyof typeof labels] ?? labels.en;
const items = [ const items = [
[labelSet.address, getLocalizedField((p as any).address, lang), MapPin], [labelSet.address, getLocalizedField(payload.address, lang), MapPin],
[labelSet.email, getLocalizedField((p as any).email, lang), Mail], [labelSet.email, getLocalizedField(payload.email, lang), Mail],
[labelSet.line, getLocalizedField((p as any).line, lang), MessageCircle], [labelSet.line, getLocalizedField(payload.line, lang), MessageCircle],
[labelSet.hours, getLocalizedField((p as any).hours, lang), Clock], [labelSet.hours, getLocalizedField(payload.hours, lang), Clock],
] as const; ] as const;
return ( return (
<section className="w-full py-16 px-4 md:px-8 bg-white"> <section className="ui-section-band w-full bg-white px-4 md:px-8">
<div className="max-w-6xl mx-auto"> <div className="ui-section-inner">
{title && ( {title && (
<div className="text-center mb-10"> <div className="mb-10 text-center">
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal"> <h2 className="font-serif text-3xl text-spa-charcoal md:text-4xl">
{title} {title}
</h2> </h2>
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-4">
{items.map(([label, value, Icon]) => { {items.map(([label, value, Icon]) => {
if (!value) return null; if (!value) return null;
return ( return (
<div key={label} className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300"> <div key={label} className="ui-shell-card flex flex-col items-center p-6 text-center">
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4"> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-spa-sage/10 text-spa-sage">
<Icon size={24} /> <Icon size={24} />
</div> </div>
<h4 className="text-lg font-serif text-spa-charcoal mb-2">{label}</h4> <h4 className="mb-2 font-serif text-lg text-spa-charcoal">{label}</h4>
{label === labelSet.email ? ( {label === labelSet.email ? (
<a href={`mailto:${value}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors"> <a href={`mailto:${value}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors">
{value} {value}
+145 -7
View File
@@ -1,12 +1,9 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-sans: 'Sarabun', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-sans: var(--font-sans-active, var(--font-sans-locale, 'Sarabun')), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-heading: 'Prompt', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-heading: var(--font-heading-active, var(--font-heading-locale, 'Prompt')), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, 'Prompt', serif; --font-serif: var(--font-serif-active, var(--font-serif-locale, ui-serif)), Georgia, Cambria, "Times New Roman", Times, serif;
--font-sans-ja: '"Noto Sans JP"', ui-sans-serif, system-ui, sans-serif;
--font-sans-zh: '"Noto Sans SC"', ui-sans-serif, system-ui, sans-serif;
--font-sans-ko: '"Noto Sans KR"', ui-sans-serif, system-ui, sans-serif;
--color-pop-pink: #FF6B6B; --color-pop-pink: #FF6B6B;
--color-pop-yellow: #FFF200; --color-pop-yellow: #FFF200;
@@ -20,10 +17,13 @@
/* Spa Theme Colors */ /* Spa Theme Colors */
--color-spa-light: #FDFDF9; --color-spa-light: #FDFDF9;
--color-spa-sand: #F4EDE2;
--color-spa-sage: #EAEFEA; --color-spa-sage: #EAEFEA;
--color-spa-stone: #4B4B46; --color-spa-stone: #4B4B46;
--color-spa-charcoal: #2E2E2A;
--color-spa-forest: #1F3D2D; --color-spa-forest: #1F3D2D;
--color-spa-gold: #D4AF37; --color-spa-gold: #D4AF37;
--color-night-900: #111827;
--shadow-brutal: 0 10px 25px rgba(0,0,0,0.05); --shadow-brutal: 0 10px 25px rgba(0,0,0,0.05);
--shadow-brutal-hover: 0 20px 40px rgba(0,0,0,0.1); --shadow-brutal-hover: 0 20px 40px rgba(0,0,0,0.1);
@@ -38,6 +38,37 @@
} }
@layer base { @layer base {
:root,
html {
--font-sans-locale: 'Sarabun';
--font-heading-locale: 'Prompt';
--font-serif-locale: ui-serif;
}
html[lang="en"] {
--font-sans-locale: 'Inter';
--font-heading-locale: 'Inter';
--font-serif-locale: ui-serif;
}
html[lang="ja"] {
--font-sans-locale: '"Noto Sans JP"';
--font-heading-locale: '"Noto Sans JP"';
--font-serif-locale: '"Noto Serif JP"';
}
html[lang="zh"] {
--font-sans-locale: '"Noto Sans SC"';
--font-heading-locale: '"Noto Sans SC"';
--font-serif-locale: '"Noto Serif SC"';
}
html[lang="ko"] {
--font-sans-locale: '"Noto Sans KR"';
--font-heading-locale: '"Noto Sans KR"';
--font-serif-locale: '"Noto Serif KR"';
}
body { body {
@apply bg-pop-bg text-pop-black font-sans antialiased; @apply bg-pop-bg text-pop-black font-sans antialiased;
background-image: radial-gradient(circle at top right, rgba(255,107,107,0.15), transparent 40%), background-image: radial-gradient(circle at top right, rgba(255,107,107,0.15), transparent 40%),
@@ -51,6 +82,114 @@
} }
@layer components { @layer components {
.ui-page-shell {
@apply min-h-screen bg-pop-bg;
}
.ui-section-band {
@apply px-6 py-16 md:px-8 md:py-24;
}
.ui-section-inner {
@apply mx-auto max-w-7xl;
}
.ui-section-title {
@apply text-3xl font-semibold text-pop-black md:text-5xl;
}
.ui-section-subtitle {
@apply mx-auto max-w-2xl text-gray-500;
}
.ui-shell-card {
@apply rounded-3xl border border-spa-stone/10 bg-white shadow-brutal transition-all duration-300;
}
.ui-shell-card-hover {
@apply hover:-translate-y-1 hover:shadow-brutal-hover;
}
.ui-chip {
@apply inline-flex items-center justify-center rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-widest transition;
}
.ui-button-primary {
@apply inline-flex items-center justify-center rounded-full bg-spa-forest px-6 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors hover:bg-spa-forest/90;
}
.ui-button-secondary {
@apply inline-flex items-center justify-center rounded-full bg-white px-6 py-3 text-xs font-bold uppercase tracking-widest text-spa-forest transition-colors hover:bg-spa-sage;
}
.ui-input-shell {
@apply w-full rounded-full border border-white/10 bg-white/5 px-6 py-4 text-sm text-white outline-none transition placeholder:text-white/40 focus:border-spa-gold/80;
}
.ui-muted-text {
@apply text-spa-stone/70;
}
.ui-hero-shell {
@apply relative flex min-h-[90vh] items-center justify-center overflow-hidden bg-night-900 text-white md:aspect-[21/9] md:min-h-0;
}
.ui-hero-overlay {
@apply absolute inset-0 bg-neutral-900/40;
}
.ui-hero-overlay-strong {
@apply absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30;
}
.ui-hero-content {
@apply relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0;
}
.ui-hero-title {
@apply font-serif text-5xl font-light leading-tight tracking-wide text-white drop-shadow-md md:text-7xl lg:text-8xl;
}
.ui-hero-copy {
@apply mx-auto max-w-2xl pb-8 pt-4 text-lg font-light text-white/90 drop-shadow-md md:text-xl;
}
.ui-banner-shell {
@apply relative mx-auto min-h-[350px] max-w-7xl overflow-hidden rounded-3xl shadow-brutal;
}
.ui-banner-overlay {
@apply absolute inset-0 bg-gradient-to-r from-spa-forest/80 via-spa-forest/40 to-transparent;
}
.ui-banner-content {
@apply relative z-10 max-w-xl space-y-4 p-8 text-white md:p-16;
}
.ui-card-soft {
@apply rounded-2xl border border-spa-stone/10 bg-white shadow-sm;
}
.ui-dark-shell {
@apply bg-spa-forest px-6 py-20 text-white;
}
.ui-dark-card {
@apply overflow-hidden rounded-3xl border border-white/5 bg-white/5;
}
.ui-dark-card-title {
@apply text-xl font-serif font-light tracking-wide text-spa-light md:text-2xl;
}
.ui-dark-card-copy {
@apply text-sm font-light leading-relaxed text-spa-light/70;
}
.ui-dark-link {
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-spa-gold transition-colors hover:text-white;
}
.brutal-box { .brutal-box {
@apply bg-white border border-gray-100 rounded-2xl shadow-brutal transition-all duration-300; @apply bg-white border border-gray-100 rounded-2xl shadow-brutal transition-all duration-300;
} }
@@ -104,4 +243,3 @@ html {
:lang(th) .font-serif { :lang(th) .font-serif {
font-family: 'Prompt', sans-serif !important; font-family: 'Prompt', sans-serif !important;
} }
+3 -2
View File
@@ -4,6 +4,7 @@ import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms' import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { toneClass } from '@/lib/theme'
// lang is extracted from params // lang is extracted from params
function formatDiscount(type: string, value: number) { function formatDiscount(type: string, value: number) {
@@ -62,7 +63,7 @@ export default async function CouponsPage({ params }: { params: Promise<{ lang:
<div className="mx-auto max-w-6xl px-6 pb-24"> <div className="mx-auto max-w-6xl px-6 pb-24">
{!couponsResult.ok ? ( {!couponsResult.ok ? (
<div className="ui-panel border border-rose-100 px-6 py-20 text-center"> <div className={`ui-panel px-6 py-20 text-center ${toneClass('error')}`}>
<Ticket className="mx-auto mb-4 h-16 w-16 text-rose-200" /> <Ticket className="mx-auto mb-4 h-16 w-16 text-rose-200" />
<h2 className="mb-2 text-xl font-semibold text-text-main">ไม่สามารถโหลดคูปองได้</h2> <h2 className="mb-2 text-xl font-semibold text-text-main">ไม่สามารถโหลดคูปองได้</h2>
<p className="text-sm text-rose-500">{couponsResult.error}</p> <p className="text-sm text-rose-500">{couponsResult.error}</p>
@@ -92,7 +93,7 @@ export default async function CouponsPage({ params }: { params: Promise<{ lang:
<Ticket className="h-6 w-6" /> <Ticket className="h-6 w-6" />
</div> </div>
{coupon.expiresAt && ( {coupon.expiresAt && (
<span className="rounded-md bg-amber-50 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-amber-600"> <span className={`rounded-md px-2 py-1 text-[10px] font-bold uppercase tracking-wider ${toneClass('warning')}`}>
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')} หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
</span> </span>
)} )}
@@ -9,6 +9,7 @@ import { formatPrice } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer' import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '@/lib/cms' import { getCategoriesResult, getCmsPageResult, getOrderResult } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections' import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { orderStatusBadgeClass, toneClass } from '@/lib/theme'
// lang is extracted from params // lang is extracted from params
function orderLabel(id: number) { function orderLabel(id: number) {
@@ -28,14 +29,6 @@ function statusLabel(status: string) {
return labels[status] ?? status.replaceAll('_', ' ') return labels[status] ?? status.replaceAll('_', ' ')
} }
function statusTone(status: string) {
if (status === 'PENDING_PAYMENT') return 'bg-brand-warm/15 text-amber-700 border-amber-100'
if (status === 'PAID' || status === 'PROCESSING') return 'bg-brand-success/20 text-emerald-700 border-emerald-100'
if (status === 'SHIPPED' || status === 'DELIVERED') return 'bg-brand-cool/40 text-sky-700 border-sky-100'
if (status === 'CANCELLED') return 'bg-rose-50 text-rose-700 border-rose-100'
return 'bg-surface-page text-muted-text border-line-primary/20'
}
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params const { id, lang } = await params
const result = await getOrderResult(id) const result = await getOrderResult(id)
@@ -74,7 +67,7 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} /> <DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)} )}
<section className="mx-auto max-w-3xl px-6 py-24 text-center"> <section className="mx-auto max-w-3xl px-6 py-24 text-center">
<div className="ui-panel border border-rose-100 p-10"> <div className={`ui-panel p-10 ${toneClass('error')}`}>
<h1 className="text-2xl font-semibold text-text-main">ไม่สามารถโหลดคำสั่งซื้อนี้ได้</h1> <h1 className="text-2xl font-semibold text-text-main">ไม่สามารถโหลดคำสั่งซื้อนี้ได้</h1>
<p className="mt-3 text-sm text-rose-500">{orderResult.error}</p> <p className="mt-3 text-sm text-rose-500">{orderResult.error}</p>
<Link href="/track" className="mt-6 inline-flex font-semibold text-brand-primary">กลับไปค้นหาคำสั่งซื้อ</Link> <Link href="/track" className="mt-6 inline-flex font-semibold text-brand-primary">กลับไปค้นหาคำสั่งซื้อ</Link>
@@ -116,7 +109,7 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
<article className="ui-panel p-6"> <article className="ui-panel p-6">
<PackageCheck className="mb-4 h-6 w-6 text-brand-primary" /> <PackageCheck className="mb-4 h-6 w-6 text-brand-primary" />
<p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">Status</p> <p className="text-xs font-bold uppercase tracking-[0.22em] text-muted-text/70">Status</p>
<span className={`mt-3 inline-flex rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-[0.14em] ${statusTone(order.status)}`}> <span className={`mt-3 inline-flex rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.14em] ${orderStatusBadgeClass(order.status)}`}>
{statusLabel(order.status)} {statusLabel(order.status)}
</span> </span>
</article> </article>
@@ -177,7 +170,7 @@ export default async function OrderStatusPage({ params }: { params: Promise<{ id
</section> </section>
{order.status === 'PENDING_PAYMENT' && ( {order.status === 'PENDING_PAYMENT' && (
<section className="rounded-3xl border border-amber-100 bg-brand-warm/15 p-6 text-amber-800"> <section className={toneClass('warning')}>
<h2 className="font-semibold">คำสั่งซื้อนี้ยังรอการชำระเงิน</h2> <h2 className="font-semibold">คำสั่งซื้อนี้ยังรอการชำระเงิน</h2>
<p className="mt-2 text-sm leading-relaxed"> <p className="mt-2 text-sm leading-relaxed">
หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow หน้านี้รองรับการตรวจสอบสถานะคำสั่งซื้อแล้ว ส่วนการชำระเงินและอัปโหลดสลิปบน website จะย้ายมาในรอบถัดไป เพื่อไม่ทำ workaround ที่เสี่ยงกับ payment flow
+248 -7
View File
@@ -1,10 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--font-body: var(--font-body-active, var(--font-body-locale, ui-sans-serif)), system-ui, sans-serif;
--font-display: var(--font-display-active, var(--font-display-locale, var(--font-body-locale, ui-sans-serif))), system-ui, sans-serif;
--font-sans: var(--font-body);
--color-brand-primary: #FF6B6B; --color-brand-primary: #FF6B6B;
--color-brand-primary-hover: #FF4500; --color-brand-primary-hover: #FF4500;
--color-brand-accent: #FFF200; --color-brand-accent: #FFF200;
@@ -47,8 +43,8 @@
@layer base { @layer base {
:root, :root,
html { html {
--font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif; --font-body-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale); --font-display-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
} }
html[lang="en"] { html[lang="en"] {
@@ -76,8 +72,12 @@
--font-display-locale: var(--font-body-locale); --font-display-locale: var(--font-body-locale);
} }
html {
font-family: var(--font-body-locale);
}
body { body {
@apply bg-surface-page text-text-main font-sans antialiased; @apply bg-surface-page text-text-main antialiased;
background-image: radial-gradient(circle at top right, rgba(255,107,107,0.15), transparent 40%), background-image: radial-gradient(circle at top right, rgba(255,107,107,0.15), transparent 40%),
radial-gradient(circle at bottom left, rgba(255,160,122,0.15), transparent 40%); radial-gradient(circle at bottom left, rgba(255,160,122,0.15), transparent 40%);
min-height: 100vh; min-height: 100vh;
@@ -85,6 +85,7 @@
h1, h2, h3, h4 { h1, h2, h3, h4 {
@apply text-text-main font-semibold tracking-tight; @apply text-text-main font-semibold tracking-tight;
font-family: var(--font-display-locale);
} }
} }
@@ -205,6 +206,188 @@
@apply italic text-brand-accent; @apply italic text-brand-accent;
} }
.ui-hero-shell {
position: relative;
display: flex;
min-height: 90vh;
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--color-text-main);
color: #fff;
}
.ui-hero-overlay {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--color-text-main) 40%, transparent);
}
.ui-hero-overlay-strong {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
color-mix(in srgb, var(--color-text-main) 90%, transparent),
color-mix(in srgb, var(--color-text-main) 10%, transparent),
color-mix(in srgb, var(--color-text-main) 30%, transparent)
);
}
.ui-hero-title {
color: #fff;
font-family: var(--font-display-locale);
font-size: 3rem;
font-weight: 300;
line-height: 1.25;
letter-spacing: 0.025em;
filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
}
.ui-hero-copy {
margin-inline: auto;
max-width: 42rem;
padding-top: 1rem;
padding-bottom: 2rem;
color: rgb(255 255 255 / 0.9);
font-size: 1.125rem;
font-weight: 300;
line-height: 1.75rem;
filter: drop-shadow(0 4px 3px rgb(0 0 0 / 0.07)) drop-shadow(0 2px 2px rgb(0 0 0 / 0.06));
}
.ui-hero-button {
border-radius: 9999px;
background: var(--color-surface-card);
padding: 1rem 2rem;
color: var(--color-text-main);
font-size: 0.875rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: background-color 300ms ease, box-shadow 300ms ease;
}
.ui-hero-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 150ms ease;
}
.ui-banner-shell {
position: relative;
margin-inline: auto;
min-height: 360px;
max-width: 80rem;
overflow: hidden;
border-radius: 1.5rem;
box-shadow: var(--shadow-card);
}
.ui-banner-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
color-mix(in srgb, var(--color-text-main) 75%, transparent),
color-mix(in srgb, var(--color-text-main) 35%, transparent),
transparent
);
}
.ui-banner-content {
position: relative;
z-index: 10;
max-width: 36rem;
padding: 2rem;
color: #fff;
}
.ui-banner-content > * + * {
margin-top: 1rem;
}
.ui-section-dark {
background: var(--color-text-main);
padding: 5rem 1.5rem;
color: #fff;
}
.ui-section-dark-card {
overflow: hidden;
border-radius: 1.5rem;
border: 1px solid color-mix(in srgb, var(--color-line-primary) 10%, transparent);
background: color-mix(in srgb, var(--color-surface-card) 60%, transparent);
}
.ui-section-dark-card-title {
color: #fff;
font-size: 2.25rem;
font-weight: 600;
line-height: 2.5rem;
}
.ui-section-dark-card-copy {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
color: rgb(255 255 255 / 0.7);
font-size: 0.875rem;
line-height: 1.625;
}
.ui-section-dark-link {
display: inline-flex;
color: var(--color-brand-accent);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
transition: color 150ms ease;
}
@media (min-width: 768px) {
.ui-hero-shell {
aspect-ratio: 21 / 9;
min-height: 0;
}
.ui-hero-title {
font-size: 4.5rem;
line-height: 1;
}
.ui-hero-copy {
font-size: 1.25rem;
line-height: 1.75rem;
}
.ui-banner-content {
padding: 3.5rem;
}
.ui-section-dark-card-title {
font-size: 3rem;
line-height: 1;
}
}
@media (min-width: 1024px) {
.ui-hero-title {
font-size: 6rem;
line-height: 1;
}
}
.ui-panel { .ui-panel {
@apply rounded-3xl bg-white shadow-card; @apply rounded-3xl bg-white shadow-card;
} }
@@ -265,6 +448,64 @@
@apply rounded-full border px-3 py-1 text-xs font-bold; @apply rounded-full border px-3 py-1 text-xs font-bold;
} }
.ui-status-badge-pending {
border-color: rgb(254 243 199);
background: rgb(255 251 235);
color: rgb(180 83 9);
}
.ui-status-badge-active {
border-color: rgb(209 250 229);
background: rgb(236 253 245);
color: rgb(4 120 87);
}
.ui-status-badge-shipped {
border-color: rgb(224 242 254);
background: rgb(240 249 255);
color: rgb(3 105 161);
}
.ui-status-badge-cancelled {
border-color: rgb(255 228 230);
background: rgb(255 241 242);
color: rgb(190 18 60);
}
.ui-status-badge-neutral {
border-color: color-mix(in srgb, var(--color-line-primary) 20%, transparent);
background: var(--color-surface-page);
color: color-mix(in srgb, var(--color-text-main) 80%, transparent);
}
.ui-tone-warning {
border-radius: 1rem;
border: 1px solid rgb(254 243 199);
background: rgb(255 251 235);
color: rgb(146 64 14);
}
.ui-tone-success {
border-radius: 1rem;
border: 1px solid rgb(209 250 229);
background: rgb(236 253 245);
color: rgb(4 120 87);
}
.ui-tone-error {
border-radius: 1rem;
border: 1px solid rgb(255 228 230);
background: rgb(255 241 242);
color: rgb(190 18 60);
}
.ui-tone-info {
border-radius: 1rem;
border: 1px solid rgb(224 242 254);
background: rgb(240 249 255);
color: rgb(3 105 161);
}
.ui-avatar-shell { .ui-avatar-shell {
@apply flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border border-brand-primary/20 bg-surface-page; @apply flex h-20 w-20 items-center justify-center overflow-hidden rounded-full border border-brand-primary/20 bg-surface-page;
} }
+11 -10
View File
@@ -12,6 +12,7 @@ import { useAuthStore, type Customer } from '../store/authStore'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
import { getPathWithLocale } from '../lib/locales' import { getPathWithLocale } from '../lib/locales'
import { toneClass } from '../lib/theme'
type StoreSettings = { type StoreSettings = {
shippingFee?: number shippingFee?: number
@@ -571,10 +572,10 @@ export default function CheckoutClient() {
</section> </section>
{submitError && ( {submitError && (
<div className="flex gap-3 rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm text-rose-700"> <div className={`flex gap-3 p-4 text-sm ${toneClass('error')}`}>
<AlertCircle className="h-5 w-5 shrink-0" /> <AlertCircle className="h-5 w-5 shrink-0" />
<p>{submitError}</p> <p>{submitError}</p>
</div> </div>
)} )}
<button <button
@@ -641,15 +642,15 @@ export default function CheckoutClient() {
type="button" type="button"
onClick={applyCoupon} onClick={applyCoupon}
disabled={couponLoading || !couponCode.trim()} disabled={couponLoading || !couponCode.trim()}
className="rounded-2xl bg-text-main px-4 py-3 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-brand-primary disabled:opacity-50" className="rounded-2xl bg-text-main px-4 py-3 text-xs font-bold uppercase tracking-widest text-white transition hover:bg-brand-primary disabled:opacity-50"
> >
{couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ใช้'} {couponLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ใช้'}
</button> </button>
</div> </div>
{couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>} {couponError && <p className="text-xs font-semibold text-rose-500">{couponError}</p>}
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between rounded-2xl border border-emerald-100 bg-emerald-50 p-4"> <div className={`flex items-center justify-between p-4 ${toneClass('success')}`}>
<span className="flex items-center gap-2 text-sm font-bold text-emerald-700"> <span className="flex items-center gap-2 text-sm font-bold text-emerald-700">
<Ticket className="h-4 w-4" /> <Ticket className="h-4 w-4" />
{appliedCoupon} {appliedCoupon}
@@ -742,7 +743,7 @@ export default function CheckoutClient() {
</div> </div>
</div> </div>
<div className="rounded-2xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-800"> <div className={`p-4 text-sm ${toneClass('warning')}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" /> <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<p> <p>
+8 -15
View File
@@ -23,6 +23,7 @@ import {
import { formatPrice, type CustomerAddress, type Order } from '@homaroma/types' import { formatPrice, type CustomerAddress, type Order } from '@homaroma/types'
import { API_BASE } from '../lib/api' import { API_BASE } from '../lib/api'
import { orderStatusBadgeClass, toneClass } from '../lib/theme'
import { useAuthStore, type Customer } from '../store/authStore' import { useAuthStore, type Customer } from '../store/authStore'
type ProfileTab = 'orders' | 'addresses' | 'settings' type ProfileTab = 'orders' | 'addresses' | 'settings'
@@ -74,14 +75,6 @@ function statusLabel(status: string) {
return labels[status] ?? status.replaceAll('_', ' ') return labels[status] ?? status.replaceAll('_', ' ')
} }
function statusClass(status: string) {
if (status === 'PENDING_PAYMENT') return 'border-amber-100 bg-amber-50 text-amber-700'
if (status === 'PAID' || status === 'PROCESSING') return 'border-emerald-100 bg-emerald-50 text-emerald-700'
if (status === 'SHIPPED' || status === 'DELIVERED') return 'border-sky-100 bg-sky-50 text-sky-700'
if (status === 'CANCELLED') return 'border-rose-100 bg-rose-50 text-rose-700'
return 'border-line-primary/20 bg-surface-page text-text-main/80'
}
function formatAddress(address: CustomerAddress) { function formatAddress(address: CustomerAddress) {
return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}` return `${address.addressLine} ต.${address.subdistrict} อ.${address.district} จ.${address.province} ${address.zipcode}`
} }
@@ -294,7 +287,7 @@ export default function ProfileClient() {
ล็อกอินเพื่อตรวจสอบประวัติคำสั่งซื้อ สะสมคะแนนสมาชิก และบันทึกสมุดที่อยู่จัดส่งสินค้า ล็อกอินเพื่อตรวจสอบประวัติคำสั่งซื้อ สะสมคะแนนสมาชิก และบันทึกสมุดที่อยู่จัดส่งสินค้า
</p> </p>
{error && ( {error && (
<p className="mt-5 rounded-2xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-700"> <p className={`mt-5 p-4 text-sm ${toneClass('warning')}`}>
{error} {error}
</p> </p>
)} )}
@@ -323,7 +316,7 @@ export default function ProfileClient() {
return ( return (
<section className="space-y-8"> <section className="space-y-8">
{error && ( {error && (
<div className="rounded-3xl border border-rose-100 bg-rose-50 p-5 text-sm font-medium text-rose-600"> <div className={`p-5 text-sm font-medium ${toneClass('error')}`}>
{error} {error}
</div> </div>
)} )}
@@ -398,7 +391,7 @@ export default function ProfileClient() {
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`ui-status-badge ${statusClass(order.status)}`}> <span className={orderStatusBadgeClass(order.status)}>
{statusLabel(order.status)} {statusLabel(order.status)}
</span> </span>
<Link href={`/order/${order.id}`} className="rounded-full border border-line-primary/20 p-2 text-muted-text transition hover:text-brand-primary"> <Link href={`/order/${order.id}`} className="rounded-full border border-line-primary/20 p-2 text-muted-text transition hover:text-brand-primary">
@@ -534,10 +527,10 @@ export default function ProfileClient() {
<form onSubmit={handleSettingsSubmit} className="ui-panel max-w-xl space-y-5 p-6"> <form onSubmit={handleSettingsSubmit} className="ui-panel max-w-xl space-y-5 p-6">
{settingsSuccess && ( {settingsSuccess && (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 p-4 text-sm font-bold text-emerald-700"> <div className={`flex items-center justify-center gap-2 p-4 text-sm font-bold ${toneClass('success')}`}>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
บันทึกข้อมูลติดต่อสำเร็จแล้ว บันทึกข้อมูลติดต่อสำเร็จแล้ว
</div> </div>
)} )}
<TextInput label="อีเมล" value={settingsForm.email} onChange={(email) => setSettingsForm((current) => ({ ...current, email }))} type="email" /> <TextInput label="อีเมล" value={settingsForm.email} onChange={(email) => setSettingsForm((current) => ({ ...current, email }))} type="email" />
<TextInput label="เบอร์โทรศัพท์" value={settingsForm.phone} onChange={(phone) => setSettingsForm((current) => ({ ...current, phone }))} type="tel" /> <TextInput label="เบอร์โทรศัพท์" value={settingsForm.phone} onChange={(phone) => setSettingsForm((current) => ({ ...current, phone }))} type="tel" />
@@ -8,6 +8,7 @@ import { ChevronRight, Loader2, Package, Search } from 'lucide-react'
import { API_BASE } from '../lib/api' import { API_BASE } from '../lib/api'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
import { getPathWithLocale } from '../lib/locales' import { getPathWithLocale } from '../lib/locales'
import { toneClass } from '../lib/theme'
type TrackResponse = type TrackResponse =
| { success: true; orderId: number } | { success: true; orderId: number }
@@ -105,7 +106,7 @@ export default function TrackOrderClient() {
<form className="space-y-5" onSubmit={handleSubmit}> <form className="space-y-5" onSubmit={handleSubmit}>
{error && ( {error && (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm font-medium text-rose-600"> <div className={`p-4 text-sm font-medium ${toneClass('error')}`}>
{error} {error}
</div> </div>
)} )}
@@ -79,8 +79,8 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
if (!slides.length) return null if (!slides.length) return null
return ( return (
<section <section
className={`announcement-bar absolute top-0 left-0 z-[60] w-full overflow-hidden px-4 py-3 text-center text-sm font-semibold tracking-wide ${backgroundColor ? '' : 'bg-text-main'} ${textColor ? '' : 'text-white'}`} className={`announcement-bar absolute left-0 top-0 z-[60] w-full overflow-hidden px-4 py-3 text-center text-sm font-semibold tracking-wide ${backgroundColor ? '' : 'bg-text-main'} ${textColor ? '' : 'text-white'}`}
style={{ backgroundColor, color: textColor }} style={{ backgroundColor, color: textColor }}
> >
<Swiper <Swiper
@@ -112,7 +112,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop' const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
return ( return (
<section className="relative flex min-h-[90vh] w-full items-center justify-center overflow-hidden bg-text-main md:aspect-[21/9] md:min-h-0"> <section className="ui-hero-shell">
<Image <Image
src={imageUrl} src={imageUrl}
alt={title || 'HomAroma'} alt={title || 'HomAroma'}
@@ -120,8 +120,8 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
unoptimized unoptimized
className="absolute inset-0 h-full w-full scale-[1.02] object-cover" className="absolute inset-0 h-full w-full scale-[1.02] object-cover"
/> />
<div className="absolute inset-0 bg-text-main/40" /> <div className="ui-hero-overlay" />
<div className="absolute inset-0 bg-gradient-to-t from-text-main/90 via-text-main/10 to-text-main/30" /> <div className="ui-hero-overlay-strong" />
<div className="relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0"> <div className="relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0">
<div className="space-y-6"> <div className="space-y-6">
{tagline && ( {tagline && (
@@ -129,24 +129,24 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
{tagline} {tagline}
</p> </p>
)} )}
<h1 className="font-display text-5xl font-light leading-tight tracking-wide text-white drop-shadow-md md:text-7xl lg:text-8xl"> <h1 className="ui-hero-title">
{renderAccentTitle(title || 'HomAroma')} {renderAccentTitle(title || 'HomAroma')}
</h1> </h1>
{description && ( {description && (
<p className="mx-auto max-w-2xl pb-8 pt-4 text-lg font-light text-white/90 drop-shadow-md md:text-xl"> <p className="ui-hero-copy">
{description} {description}
</p> </p>
)} )}
<div className="flex flex-col items-center justify-center gap-6 sm:flex-row"> <div className="flex flex-col items-center justify-center gap-6 sm:flex-row">
<Link <Link
href={buttonLink} href={buttonLink}
className="rounded-full bg-surface-card px-8 py-4 text-sm font-bold uppercase tracking-widest text-text-main transition-all duration-300 hover:bg-surface-page hover:shadow-xl" className="ui-hero-button"
> >
{buttonText || 'Explore Collection'} {buttonText || 'Explore Collection'}
</Link> </Link>
<a <a
href="#categories" href="#categories"
className="group flex items-center gap-2 px-8 py-4 text-sm font-medium uppercase tracking-widest text-white transition-colors hover:text-brand-accent" className="ui-hero-link"
> >
{heroLearnMoreText(resolvedLang)} {heroLearnMoreText(resolvedLang)}
<span className="transition-transform group-hover:translate-x-1">→</span> <span className="transition-transform group-hover:translate-x-1">→</span>
@@ -285,10 +285,10 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : '' const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
const content = ( const content = (
<div className="relative mx-auto min-h-[360px] max-w-7xl overflow-hidden rounded-3xl shadow-card"> <div className="ui-banner-shell">
<Image src={imageUrl} alt={title || 'Banner'} fill unoptimized className="absolute inset-0 h-full w-full object-cover" /> <Image src={imageUrl} alt={title || 'Banner'} fill unoptimized className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-text-main/75 via-text-main/35 to-transparent" /> <div className="ui-banner-overlay" />
<div className="relative z-10 max-w-xl space-y-4 p-8 text-white md:p-14"> <div className="ui-banner-content">
{title && <h2 className="text-3xl font-semibold md:text-5xl">{title}</h2>} {title && <h2 className="text-3xl font-semibold md:text-5xl">{title}</h2>}
{description && <p className="text-white/90">{description}</p>} {description && <p className="text-white/90">{description}</p>}
{buttonText && <span className="inline-flex rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main">{buttonText}</span>} {buttonText && <span className="inline-flex rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main">{buttonText}</span>}
@@ -329,15 +329,15 @@ function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>
const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com' const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com'
return ( return (
<section className="bg-text-main px-6 py-20 text-white"> <section className="ui-section-dark">
<div className="mx-auto grid max-w-5xl gap-8 md:grid-cols-[1fr_auto] md:items-end"> <div className="mx-auto grid max-w-5xl gap-8 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-3xl font-semibold text-white md:text-4xl">{title}</h2> <h2 className="text-3xl font-semibold text-white md:text-4xl">{title}</h2>
{description && <p className="max-w-2xl text-white/75">{description}</p>} {description && <p className="max-w-2xl text-white/75">{description}</p>}
</div> </div>
<form className="flex flex-col gap-3 sm:flex-row"> <form className="flex flex-col gap-3 sm:flex-row">
<input className="min-w-0 rounded-full px-5 py-3 text-text-main" placeholder={placeholder} type="email" /> <input className="ui-form-shell min-w-0 rounded-full" placeholder={placeholder} type="email" />
<button className="rounded-full bg-brand-primary px-6 py-3 font-semibold text-white" type="button"> <button className="ui-button-brand rounded-full" type="button">
{buttonText} {buttonText}
</button> </button>
</form> </form>
@@ -381,7 +381,7 @@ function EditorialTileSection({ payload, lang }: { payload: Record<string, unkno
<div className="h-px w-14 bg-brand-primary" /> <div className="h-px w-14 bg-brand-primary" />
{description && <p className="whitespace-pre-line text-base leading-relaxed text-muted-text md:text-lg" style={{ color: textColor }}>{description}</p>} {description && <p className="whitespace-pre-line text-base leading-relaxed text-muted-text md:text-lg" style={{ color: textColor }}>{description}</p>}
{buttonText && buttonLink && ( {buttonText && buttonLink && (
<Link href={buttonLink} className="inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-brand-primary hover:text-white"> <Link href={buttonLink} className="ui-section-link inline-flex rounded-full border border-current px-8 py-3 text-xs font-bold uppercase tracking-[0.22em] transition hover:bg-brand-primary hover:text-white">
{buttonText} {buttonText}
</Link> </Link>
)} )}
@@ -399,7 +399,7 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
if (!slides.length) return null if (!slides.length) return null
return ( return (
<section className="bg-text-main px-6 py-20 text-white"> <section className="ui-section-dark">
<div className="mx-auto max-w-7xl space-y-12"> <div className="mx-auto max-w-7xl space-y-12">
{title && ( {title && (
<div className="mx-auto max-w-3xl space-y-4 text-center"> <div className="mx-auto max-w-3xl space-y-4 text-center">
@@ -417,7 +417,7 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : '' const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
return ( return (
<article key={index} className="group overflow-hidden rounded-3xl border border-line-primary/10 bg-surface-card/60"> <article key={index} className="ui-section-dark-card group">
{imageUrl && ( {imageUrl && (
<div className="relative aspect-[4/3] overflow-hidden"> <div className="relative aspect-[4/3] overflow-hidden">
<Image <Image
@@ -431,10 +431,10 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
)} )}
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
{category && <p className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-accent">{category}</p>} {category && <p className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-accent">{category}</p>}
{slideTitle && <h3 className="text-2xl font-semibold text-white">{renderAccentTitle(slideTitle)}</h3>} {slideTitle && <h3 className="ui-section-dark-card-title">{renderAccentTitle(slideTitle)}</h3>}
{description && <p className="line-clamp-4 text-sm leading-relaxed text-white/70">{description}</p>} {description && <p className="ui-section-dark-card-copy">{description}</p>}
{buttonText && buttonLink && ( {buttonText && buttonLink && (
<Link href={buttonLink} className="inline-flex text-xs font-bold uppercase tracking-[0.22em] text-brand-accent transition hover:text-white"> <Link href={buttonLink} className="ui-section-dark-link">
{buttonText} &rarr; {buttonText} &rarr;
</Link> </Link>
)} )}
@@ -475,16 +475,16 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
} }
return ( return (
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-text-main px-6 py-24 text-center text-white md:min-h-[76vh]"> <section className="ui-hero-shell min-h-[60vh] px-6 py-24 text-center md:min-h-[76vh]">
{imageUrl && ( {imageUrl && (
<> <>
<Image src={imageUrl} alt={title} fill unoptimized className="absolute inset-0 h-full w-full object-cover" /> <Image src={imageUrl} alt={title} fill unoptimized className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-text-main/55" /> <div className="ui-hero-overlay" />
</> </>
)} )}
<div className="relative z-10 mx-auto max-w-4xl space-y-6"> <div className="relative z-10 mx-auto max-w-4xl space-y-6">
{category && <p className="text-xs font-bold uppercase tracking-[0.28em] text-brand-accent">{category}</p>} {category && <p className="text-xs font-bold uppercase tracking-[0.28em] text-brand-accent">{category}</p>}
<h1 className="text-4xl font-semibold leading-tight md:text-6xl">{title}</h1> <h1 className="ui-hero-title">{title}</h1>
{subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>} {subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>}
{(author || date) && ( {(author || date) && (
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70"> <p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70">
+13
View File
@@ -0,0 +1,13 @@
export type ThemeTone = 'warning' | 'success' | 'error' | 'info'
export function toneClass(tone: ThemeTone) {
return `ui-tone-${tone}`
}
export function orderStatusBadgeClass(status: string) {
if (status === 'PENDING_PAYMENT') return 'ui-status-badge ui-status-badge-pending'
if (status === 'PAID' || status === 'PROCESSING') return 'ui-status-badge ui-status-badge-active'
if (status === 'SHIPPED' || status === 'DELIVERED') return 'ui-status-badge ui-status-badge-shipped'
if (status === 'CANCELLED') return 'ui-status-badge ui-status-badge-cancelled'
return 'ui-status-badge ui-status-badge-neutral'
}