x
This commit is contained in:
1 parent
c365add07f
commit
c449de2482
28 files changed
+694
-300
No files matched your search
@@ -15,13 +15,13 @@ export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementB
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
||||
const slides = (payload.slides || []).map((slide: any) => getLocalizedField(slide, lang));
|
||||
const slides = (payload.slides || []).map((slide) => getLocalizedField(slide, lang));
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<Swiper
|
||||
|
||||
@@ -15,47 +15,71 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||
|
||||
const Wrapper = linkUrl ? Link : 'div';
|
||||
const wrapperProps = linkUrl ? { to: linkUrl } : {};
|
||||
|
||||
return (
|
||||
<section className="py-16 px-6 bg-white relative overflow-hidden">
|
||||
<Wrapper
|
||||
{...(wrapperProps as any)}
|
||||
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'}`}
|
||||
>
|
||||
{/* Background Image */}
|
||||
<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"}
|
||||
/>
|
||||
{/* Overlay gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-spa-forest/80 via-spa-forest/40 to-transparent"></div>
|
||||
</div>
|
||||
<section className="ui-section-band bg-white relative overflow-hidden">
|
||||
{linkUrl ? (
|
||||
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
|
||||
<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>
|
||||
|
||||
{/* 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 && (
|
||||
<h3 className="text-3xl md:text-5xl font-serif font-light text-spa-gold leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-spa-light/95 font-light text-base md:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{buttonText && linkUrl && (
|
||||
<div className="pt-4">
|
||||
<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}
|
||||
</span>
|
||||
</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>
|
||||
</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>
|
||||
</Wrapper>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -26,41 +26,41 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
|
||||
}, [categories, categoryIds]);
|
||||
|
||||
return (
|
||||
<section id="categories" className="py-24 px-6 relative overflow-hidden bg-white">
|
||||
<div className="max-w-7xl mx-auto space-y-16 relative z-10">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight text-pop-black">
|
||||
<section id="categories" className="ui-section-band relative overflow-hidden bg-white">
|
||||
<div className="ui-section-inner space-y-16 relative z-10">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-4xl font-bold tracking-tight text-pop-black md:text-5xl">
|
||||
{title || "Our Collections"}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-gray-500 max-w-2xl mx-auto">{subtitle}</p>
|
||||
<p className="ui-section-subtitle">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<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 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) => (
|
||||
<Link
|
||||
key={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" />
|
||||
<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"
|
||||
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' }}
|
||||
/>
|
||||
<div className="absolute bottom-6 left-6 right-6 z-20 text-white">
|
||||
<span className="font-semibold text-xl block mb-1">
|
||||
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[localStorage.getItem('homaroma_lang') || 'en'] || (cat.name as any)?.en || ''}
|
||||
<span className="mb-1 block text-xl font-semibold">
|
||||
{getLocalizedField(cat.name, localStorage.getItem('homaroma_lang') || 'en')}
|
||||
</span>
|
||||
{cat._count && (
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const slides = (payload.slides || []).map((slide: any) => ({
|
||||
const slides = (payload.slides || []).map((slide) => ({
|
||||
...slide,
|
||||
title: getLocalizedField(slide.title, lang),
|
||||
description: getLocalizedField(slide.description, lang),
|
||||
@@ -46,17 +46,15 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full bg-[#1F3D2D] text-white py-20 px-6 md:px-16 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto space-y-12">
|
||||
{/* Section Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="text-3xl md:text-5xl font-light tracking-wide font-serif text-spa-gold">
|
||||
<section className="ui-dark-shell overflow-hidden">
|
||||
<div className="ui-section-inner space-y-12">
|
||||
<div className="space-y-3 text-center">
|
||||
<h2 className="font-serif text-3xl font-light tracking-wide text-spa-gold md:text-5xl">
|
||||
{renderTitle(title) || title}
|
||||
</h2>
|
||||
<div className="w-16 h-[1px] bg-spa-gold mx-auto"></div>
|
||||
</div>
|
||||
|
||||
{/* Swiper Slider */}
|
||||
<div className="relative select-none pb-12">
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination]}
|
||||
@@ -72,8 +70,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
||||
>
|
||||
{slides.map((slide, idx) => (
|
||||
<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">
|
||||
{/* Image wrapper */}
|
||||
<div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-spa-gold/30">
|
||||
<div className="aspect-[4/3] overflow-hidden relative">
|
||||
<img
|
||||
src={slide.imageUrl}
|
||||
@@ -83,19 +80,18 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
||||
(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>
|
||||
|
||||
{/* Content details */}
|
||||
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
|
||||
<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}
|
||||
</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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
||||
imageUrl,
|
||||
imagePosition = 'left',
|
||||
buttonLink,
|
||||
backgroundColor = '#FDFDF9', // Default to spa-light
|
||||
textColor = '#1F3D2D' // Default to spa-forest
|
||||
backgroundColor = '#FDFDF9',
|
||||
textColor = '#1F3D2D'
|
||||
} = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
@@ -40,13 +40,12 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="w-full overflow-hidden py-16 md:py-24 px-6 md:px-16"
|
||||
<section
|
||||
className="ui-section-band w-full overflow-hidden"
|
||||
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">
|
||||
{/* Image Column */}
|
||||
<div className={`w-full overflow-hidden rounded-3xl shadow-xl aspect-square relative group ${
|
||||
<div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20">
|
||||
<div className={`group relative aspect-square w-full overflow-hidden rounded-3xl shadow-xl ${
|
||||
imagePosition === 'left' ? 'md:order-1' : 'md:order-2'
|
||||
}`}>
|
||||
<img
|
||||
@@ -57,26 +56,24 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/* Subtle overlay */}
|
||||
<div className="absolute inset-0 bg-neutral-900/5 mix-blend-overlay"></div>
|
||||
</div>
|
||||
|
||||
{/* Content Column */}
|
||||
<div className={`space-y-6 md:space-y-8 flex flex-col justify-center ${
|
||||
<div className={`flex flex-col justify-center space-y-6 md:space-y-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}
|
||||
</h2>
|
||||
<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}
|
||||
</p>
|
||||
{buttonText && buttonLink && (
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
};
|
||||
|
||||
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">
|
||||
<img
|
||||
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';
|
||||
}}
|
||||
/>
|
||||
{/* Dark gradient overlay for premium vibe */}
|
||||
<div className="absolute inset-0 bg-neutral-900/40"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30"></div>
|
||||
<div className="ui-hero-overlay" />
|
||||
<div className="ui-hero-overlay-strong" />
|
||||
</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">
|
||||
{tagline || t('hero.defaultTagline')}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
|
||||
<h1 className="ui-hero-title">
|
||||
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
|
||||
</h1>
|
||||
<p className="text-spa-light/90 max-w-2xl mx-auto text-lg md:text-xl font-light pt-4 pb-8 drop-shadow-md">
|
||||
<p className="ui-hero-copy">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-center justify-center">
|
||||
<Link
|
||||
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')}
|
||||
</Link>
|
||||
<a
|
||||
href="#categories"
|
||||
className="px-8 py-4 text-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
|
||||
className="ui-dark-link group"
|
||||
>
|
||||
{t('hero.learnMore')}
|
||||
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { HeroArticlePayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicHeroArticleProps {
|
||||
payload: any;
|
||||
payload: HeroArticlePayload & {
|
||||
imageUrl?: string;
|
||||
date?: string;
|
||||
category?: string | { [key: string]: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) {
|
||||
@@ -16,29 +21,29 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
|
||||
const category = getLocalizedField(p.category, lang);
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-[60vh] md:min-h-[80vh] flex items-center justify-center bg-spa-sage overflow-hidden">
|
||||
<section className="ui-hero-shell min-h-[60vh] md:min-h-[80vh]">
|
||||
{imageUrl && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<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 className="relative z-10 text-center max-w-4xl px-6 text-white">
|
||||
<div className="ui-hero-content text-white">
|
||||
{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}
|
||||
</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')}
|
||||
</h1>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{(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 && date && <span>•</span>}
|
||||
{date && <span>{date}</span>}
|
||||
|
||||
@@ -53,19 +53,18 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 px-6 bg-spa-forest text-white relative overflow-hidden">
|
||||
{/* Background decorations for luxury aesthetic */}
|
||||
<section className="ui-dark-shell relative overflow-hidden">
|
||||
<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 bottom-10 right-10 w-96 h-96 rounded-full bg-spa-sage filter blur-3xl"></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">
|
||||
<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')}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -76,7 +75,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
<p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto">
|
||||
<form onSubmit={handleSubmit} className="mx-auto max-w-lg space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="email"
|
||||
@@ -85,12 +84,12 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
placeholder={placeholderText}
|
||||
value={email}
|
||||
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
|
||||
type="submit"
|
||||
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}
|
||||
</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 DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
@@ -73,11 +73,11 @@ export default function DynamicPageRenderer({
|
||||
case 'PRODUCT_CAROUSEL':
|
||||
return <DynamicProductCarousel key={section.id} payload={section.payload} />;
|
||||
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':
|
||||
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':
|
||||
return <ContactFormSection key={section.id} payload={section.payload as any} />;
|
||||
return <ContactFormSection key={section.id} payload={section.payload as ContactFormSectionPayload} />;
|
||||
default:
|
||||
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||
return null;
|
||||
|
||||
@@ -24,11 +24,11 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
||||
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
|
||||
|
||||
return (
|
||||
<section className="bg-pop-bg py-24 px-6 relative">
|
||||
<div className="max-w-7xl mx-auto space-y-12 relative z-10">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end gap-6 border-b border-gray-200 pb-6">
|
||||
<section className="ui-section-band relative bg-pop-bg">
|
||||
<div className="ui-section-inner relative z-10 space-y-12">
|
||||
<div className="flex items-end justify-between gap-6 border-b border-gray-200 pb-6 md:flex-row">
|
||||
<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"}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
@@ -45,7 +45,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
||||
</div>
|
||||
|
||||
{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
|
||||
</div>
|
||||
) : layout === 'carousel' ? (
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { QuoteCalloutPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicQuoteCalloutProps {
|
||||
payload: any;
|
||||
payload: QuoteCalloutPayload & { role?: string };
|
||||
}
|
||||
|
||||
export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const quote = getLocalizedField(p.quote, lang);
|
||||
const author = getLocalizedField(p.author, lang);
|
||||
const role = getLocalizedField(p.role, lang);
|
||||
const quote = getLocalizedField(payload.quote, lang);
|
||||
const author = getLocalizedField(payload.author, lang);
|
||||
const role = getLocalizedField(payload.role, lang);
|
||||
|
||||
return (
|
||||
<section className="py-24 px-6 bg-white">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<section className="ui-section-band bg-white">
|
||||
<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">
|
||||
<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>
|
||||
<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.'}"
|
||||
</blockquote>
|
||||
{(author || role) && (
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { ScentNotesBreakdownPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicScentNotesBreakdownProps {
|
||||
payload: any;
|
||||
payload: ScentNotesBreakdownPayload;
|
||||
}
|
||||
|
||||
export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
const notes = (value: any) => Array.isArray(value)
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const notes = (value: ScentNotesBreakdownPayload['topNotes'] | ScentNotesBreakdownPayload['middleNotes'] | ScentNotesBreakdownPayload['baseNotes']) => Array.isArray(value)
|
||||
? value.map((item) => getLocalizedField(item, lang)).filter(Boolean).join(', ')
|
||||
: getLocalizedField(value, lang);
|
||||
const labels = {
|
||||
@@ -23,39 +23,39 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
|
||||
ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' }
|
||||
} as const;
|
||||
const labelSet = labels[lang as keyof typeof labels] ?? labels.en;
|
||||
const topNotes = notes(p.topNotes);
|
||||
const heartNotes = notes(p.heartNotes ?? p.middleNotes);
|
||||
const baseNotes = notes(p.baseNotes);
|
||||
const topNotes = notes(payload.topNotes);
|
||||
const heartNotes = notes(payload.middleNotes);
|
||||
const baseNotes = notes(payload.baseNotes);
|
||||
|
||||
return (
|
||||
<section className="py-20 px-6 bg-spa-light">
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-spa-forest mb-6">
|
||||
<section className="ui-section-band bg-spa-light">
|
||||
<div className="ui-section-inner text-center">
|
||||
<h2 className="mb-6 font-serif text-3xl text-spa-forest md:text-5xl">
|
||||
{title || 'Scent Notes'}
|
||||
</h2>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.top}</h3>
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="ui-shell-card p-8">
|
||||
<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">
|
||||
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.heart}</h3>
|
||||
<div className="ui-shell-card p-8">
|
||||
<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">
|
||||
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.base}</h3>
|
||||
<div className="ui-shell-card p-8">
|
||||
<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">
|
||||
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
||||
</p>
|
||||
|
||||
@@ -20,15 +20,15 @@ export default function DynamicText({ payload }: DynamicTextProps) {
|
||||
}[textAlign] || 'text-left';
|
||||
|
||||
return (
|
||||
<section className="py-16 px-6 bg-white relative">
|
||||
<div className={`max-w-4xl mx-auto space-y-6 ${alignClass}`}>
|
||||
<section className="ui-section-band bg-white relative">
|
||||
<div className={`ui-section-inner space-y-6 ${alignClass}`}>
|
||||
{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}
|
||||
</h2>
|
||||
)}
|
||||
<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 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,20 @@ import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLocalizedField } from '../utils/localization'
|
||||
interface FooterProps {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
type MenuTranslation = {
|
||||
langCode?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
type MenuItemWithTranslations = MenuItem & {
|
||||
translations?: MenuTranslation[]
|
||||
}
|
||||
|
||||
export default function Footer({ categories = [] }: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
|
||||
@@ -13,10 +23,10 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
const brandName = settings?.brandName || ''
|
||||
|
||||
const translateMenuLabel = (item: MenuItem) => {
|
||||
const translateMenuLabel = (item: MenuItemWithTranslations) => {
|
||||
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
||||
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) {
|
||||
return trans.label
|
||||
}
|
||||
@@ -41,14 +51,14 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
fetch(`${API_BASE}/menus/footer-explore`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.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)
|
||||
|
||||
fetch(`${API_BASE}/menus/footer-support`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.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)
|
||||
|
||||
@@ -61,31 +71,31 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer className="bg-black py-20 px-6 border-t border-white/5">
|
||||
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||
<footer className="bg-night-900 px-6 py-20 border-t border-white/5">
|
||||
<div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4">
|
||||
<div className="space-y-6">
|
||||
<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}
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm leading-relaxed">
|
||||
<p className="text-sm leading-relaxed text-slate-500">
|
||||
{settings?.footerDescription || t('footer.catchphrase')}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
|
||||
<>
|
||||
{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 && (
|
||||
<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 && (
|
||||
<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) => (
|
||||
<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}
|
||||
</a>
|
||||
))
|
||||
@@ -94,7 +104,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{exploreMenu.length > 0 ? (
|
||||
exploreMenu.map(item => (
|
||||
@@ -114,18 +124,18 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{categories.map((c) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')}
|
||||
</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
|
||||
@@ -123,7 +123,6 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
||||
}
|
||||
}, [topOffsetClassName])
|
||||
|
||||
useEffect(() => { setMobileOpen(false) }, [navigate])
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
@@ -152,16 +151,16 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
||||
<>
|
||||
<nav
|
||||
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'
|
||||
} 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) */}
|
||||
<div className="w-full flex items-center justify-between gap-4">
|
||||
|
||||
{/* Left: Logo */}
|
||||
<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}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -178,7 +177,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
||||
{/* Search — desktop & mobile */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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
|
||||
to="/wishlist"
|
||||
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"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
|
||||
|
||||
@@ -46,10 +46,9 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
return (
|
||||
<div
|
||||
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="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
||||
<div className="relative h-48 overflow-hidden rounded-xl bg-spa-sage/20 md:h-64">
|
||||
<img
|
||||
src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)}
|
||||
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';
|
||||
}}
|
||||
/>
|
||||
{/* Category badge */}
|
||||
<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">
|
||||
<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">
|
||||
{displayCategoryName}
|
||||
</div>
|
||||
{/* Wishlist Button */}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Shop Variant Swatches */}
|
||||
{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) => {
|
||||
const isSelected = selectedVariant?.id === variant.id;
|
||||
return (
|
||||
@@ -99,10 +94,10 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
e.stopPropagation();
|
||||
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
|
||||
? 'bg-spa-forest border-spa-forest text-white shadow-sm'
|
||||
: 'bg-white border-spa-stone/10 text-spa-stone hover:border-spa-forest/30'
|
||||
? 'border-spa-forest bg-spa-forest text-white shadow-sm'
|
||||
: 'border-spa-stone/10 bg-white text-spa-stone hover:border-spa-forest/30'
|
||||
}`}
|
||||
>
|
||||
{variant.size}
|
||||
@@ -112,14 +107,13 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Area */}
|
||||
<div className="mt-auto pt-3 flex flex-col gap-2">
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
<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)}
|
||||
</span>
|
||||
{!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')}
|
||||
</span>
|
||||
)}
|
||||
@@ -141,7 +135,7 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
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" />
|
||||
{t('cart.addToCart')}
|
||||
|
||||
@@ -5,30 +5,30 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../../utils/localization';
|
||||
|
||||
interface AccordionSectionProps {
|
||||
payload: AccordionSectionPayload;
|
||||
payload: AccordionSectionPayload & { title?: string };
|
||||
}
|
||||
|
||||
export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
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) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full py-16 px-4 md:px-8 bg-spa-sand/20">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<section className="ui-section-band w-full bg-spa-sand/20 px-4 md:px-8">
|
||||
<div className="ui-section-inner">
|
||||
{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}
|
||||
</h2>
|
||||
)}
|
||||
{payload.categories.map((category, catIndex) => (
|
||||
<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>}
|
||||
{getLocalizedField(category.name, lang)}
|
||||
</h3>
|
||||
@@ -39,17 +39,17 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||
return (
|
||||
<div
|
||||
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
|
||||
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)}
|
||||
>
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-spa-sage flex-shrink-0">
|
||||
<span className="flex-shrink-0 text-spa-sage">
|
||||
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../../utils/localization';
|
||||
|
||||
interface ContactInfoSectionProps {
|
||||
payload: ContactInfoSectionPayload;
|
||||
payload: ContactInfoSectionPayload & { title?: string };
|
||||
}
|
||||
|
||||
export default function ContactInfoSection({ payload }: ContactInfoSectionProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const title = getLocalizedField((p as any).title, lang);
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const labels = {
|
||||
th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', 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 items = [
|
||||
[labelSet.address, getLocalizedField((p as any).address, lang), MapPin],
|
||||
[labelSet.email, getLocalizedField((p as any).email, lang), Mail],
|
||||
[labelSet.line, getLocalizedField((p as any).line, lang), MessageCircle],
|
||||
[labelSet.hours, getLocalizedField((p as any).hours, lang), Clock],
|
||||
[labelSet.address, getLocalizedField(payload.address, lang), MapPin],
|
||||
[labelSet.email, getLocalizedField(payload.email, lang), Mail],
|
||||
[labelSet.line, getLocalizedField(payload.line, lang), MessageCircle],
|
||||
[labelSet.hours, getLocalizedField(payload.hours, lang), Clock],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<section className="w-full py-16 px-4 md:px-8 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<section className="ui-section-band w-full bg-white px-4 md:px-8">
|
||||
<div className="ui-section-inner">
|
||||
{title && (
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal">
|
||||
<div className="mb-10 text-center">
|
||||
<h2 className="font-serif text-3xl text-spa-charcoal md:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
</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]) => {
|
||||
if (!value) return null;
|
||||
|
||||
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 className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
||||
<div key={label} className="ui-shell-card flex flex-col items-center p-6 text-center">
|
||||
<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} />
|
||||
</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 ? (
|
||||
<a href={`mailto:${value}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors">
|
||||
{value}
|
||||
|
||||
Reference in new issue
Block a user