x
This commit is contained in:
1 parent
406cad801b
commit
a2da091b15
21 files changed
+2454
-63
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { FeaturedTilesSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicFeaturedTilesProps {
|
||||
payload: FeaturedTilesSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicFeaturedTiles({ payload }: DynamicFeaturedTilesProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tiles = Array.isArray(payload.tiles) ? payload.tiles : [];
|
||||
const columns = payload.columns || 2;
|
||||
|
||||
const sectionTitle = getLocalizedField(payload.title, lang);
|
||||
const sectionSubtitle = getLocalizedField(payload.subtitle, lang);
|
||||
|
||||
const getGridColsClass = (cols: number) => {
|
||||
switch (cols) {
|
||||
case 3:
|
||||
return 'grid-cols-1 md:grid-cols-3';
|
||||
case 4:
|
||||
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
||||
case 2:
|
||||
default:
|
||||
return 'grid-cols-1 md:grid-cols-2';
|
||||
}
|
||||
};
|
||||
|
||||
const getAspectClass = (ratio?: string) => {
|
||||
switch (ratio) {
|
||||
case 'portrait':
|
||||
return 'aspect-[3/4]';
|
||||
case 'tall':
|
||||
return 'aspect-[2/3]';
|
||||
case 'auto':
|
||||
return 'aspect-auto';
|
||||
case 'square':
|
||||
default:
|
||||
return 'aspect-square';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ui-section-band w-full overflow-hidden"
|
||||
style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }}
|
||||
>
|
||||
<div className="ui-section-inner space-y-12">
|
||||
{/* Optional Section Header */}
|
||||
{(sectionTitle || sectionSubtitle) && (
|
||||
<div className="text-center space-y-3 max-w-2xl mx-auto">
|
||||
{sectionTitle && (
|
||||
<h2 className="font-serif text-3xl md:text-4xl font-light tracking-wide" style={{ color: payload.textColor }}>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
)}
|
||||
{sectionSubtitle && (
|
||||
<p className="text-sm md:text-base opacity-80 font-light" style={{ color: payload.textColor }}>
|
||||
{sectionSubtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="w-12 h-[1px] bg-pop-pink mx-auto"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign Tiles Grid */}
|
||||
<div className={`grid gap-8 md:gap-12 ${getGridColsClass(columns)}`}>
|
||||
{tiles.map((tile, idx) => {
|
||||
const tileTitle = getLocalizedField(tile.title, lang);
|
||||
const tileDescription = getLocalizedField(tile.description, lang);
|
||||
const buttonText = getLocalizedField(tile.buttonText, lang);
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
{/* Image Container */}
|
||||
<div className={`relative ${getAspectClass(tile.imageAspectRatio)} w-full overflow-hidden rounded-2xl bg-neutral-100 shadow-md`}>
|
||||
{tile.imageUrl ? (
|
||||
<img
|
||||
src={tile.imageUrl}
|
||||
alt={tileTitle}
|
||||
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-neutral-200 flex items-center justify-center text-neutral-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Details */}
|
||||
<div className="space-y-2 max-w-md px-2 flex-1 flex flex-col justify-between w-full">
|
||||
<div className="space-y-2">
|
||||
{tileTitle && (
|
||||
<h3 className="font-serif text-xl md:text-2xl font-light tracking-wide text-neutral-800 group-hover:text-pop-pink transition-colors" style={{ color: payload.textColor }}>
|
||||
{tileTitle}
|
||||
</h3>
|
||||
)}
|
||||
{tileDescription && (
|
||||
<p className="text-xs md:text-sm font-light leading-relaxed opacity-80" style={{ color: payload.textColor }}>
|
||||
{tileDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
{buttonText && tile.buttonLink && (
|
||||
<div className="pt-3">
|
||||
<span className="inline-block px-7 py-3 text-xs font-medium tracking-wider uppercase transition-all duration-300 bg-neutral-900 text-white group-hover:bg-neutral-800 rounded-sm shadow-sm">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center text-center group w-full">
|
||||
{tile.buttonLink ? (
|
||||
<Link to={tile.buttonLink} className="w-full flex flex-col items-center text-center space-y-5">
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full flex flex-col items-center text-center space-y-5">
|
||||
{cardContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { HeroVideoSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicHeroVideoProps {
|
||||
payload: HeroVideoSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicHeroVideo({ payload }: DynamicHeroVideoProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
|
||||
const tagline = getLocalizedField(payload.tagline, lang);
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||
const secondaryButtonText = getLocalizedField(payload.secondaryButtonText, lang);
|
||||
|
||||
const videoUrl = payload.videoUrl;
|
||||
const posterImageUrl = payload.posterImageUrl;
|
||||
const overlayOpacity = typeof payload.overlayOpacity === 'number' ? payload.overlayOpacity : 40;
|
||||
const height = payload.height || 'large';
|
||||
|
||||
const getHeightClass = (h: string) => {
|
||||
switch (h) {
|
||||
case 'full':
|
||||
return 'h-screen';
|
||||
case 'medium':
|
||||
return 'h-[60vh] min-h-[400px]';
|
||||
case 'large':
|
||||
default:
|
||||
return 'h-[85vh] min-h-[550px]';
|
||||
}
|
||||
};
|
||||
|
||||
const renderTitle = (text: string) => {
|
||||
if (!text) return null;
|
||||
return text.split('[').map((part, i) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']');
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-pop-pink font-serif italic font-normal">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative w-full ${getHeightClass(height)} overflow-hidden flex items-center justify-center`}
|
||||
style={{ backgroundColor: payload.backgroundColor || '#0B0F17', color: payload.textColor || '#FFFFFF' }}
|
||||
>
|
||||
{/* Background Video */}
|
||||
{videoUrl ? (
|
||||
<video
|
||||
autoPlay={payload.autoplay !== false}
|
||||
muted={payload.muted !== false}
|
||||
loop={payload.loop !== false}
|
||||
playsInline
|
||||
poster={posterImageUrl}
|
||||
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<source src={videoUrl} type="video/webm" />
|
||||
{posterImageUrl && <img src={posterImageUrl} alt="Hero fallback" className="w-full h-full object-cover" />}
|
||||
</video>
|
||||
) : posterImageUrl ? (
|
||||
<img src={posterImageUrl} alt="Hero Poster" className="absolute inset-0 w-full h-full object-cover z-0" />
|
||||
) : null}
|
||||
|
||||
{/* Customizable Overlay Opacity */}
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-black transition-opacity"
|
||||
style={{ opacity: overlayOpacity / 100 }}
|
||||
></div>
|
||||
|
||||
{/* Overlay Typography Content */}
|
||||
<div className="relative z-20 mx-auto px-6 py-16 w-full max-w-5xl text-center space-y-6 flex flex-col items-center justify-center animate-fade-in">
|
||||
{tagline && (
|
||||
<p className="text-xs md:text-sm font-semibold uppercase tracking-[0.35em] text-pop-pink/90 drop-shadow-md">
|
||||
{tagline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h1 className="font-serif text-4xl md:text-6xl lg:text-7xl font-light leading-tight tracking-wide drop-shadow-lg text-white">
|
||||
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
|
||||
</h1>
|
||||
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm md:text-lg font-light leading-relaxed opacity-90 drop-shadow">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dual Action Buttons */}
|
||||
{(buttonText || secondaryButtonText) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
|
||||
{buttonText && payload.buttonLink && (
|
||||
<Link
|
||||
to={payload.buttonLink}
|
||||
className="px-8 py-3.5 bg-white text-neutral-900 font-medium text-xs uppercase tracking-widest hover:bg-pop-pink hover:text-white transition-all shadow-lg rounded-sm"
|
||||
>
|
||||
{buttonText}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{secondaryButtonText && payload.secondaryButtonLink && (
|
||||
<Link
|
||||
to={payload.secondaryButtonLink}
|
||||
className="px-8 py-3.5 bg-transparent border border-white/40 text-white font-medium text-xs uppercase tracking-widest hover:bg-white/10 hover:border-white transition-all rounded-sm backdrop-blur-sm"
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload } from '@homaroma/types';
|
||||
import DynamicHero from './DynamicHero';
|
||||
import DynamicHeroV2 from './DynamicHeroV2';
|
||||
import DynamicHeroVideo from './DynamicHeroVideo';
|
||||
import DynamicUspBar from './DynamicUspBar';
|
||||
import DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
@@ -9,6 +10,7 @@ import DynamicText from './DynamicText';
|
||||
import DynamicAnnouncementBar from './DynamicAnnouncementBar';
|
||||
import DynamicNewsletter from './DynamicNewsletter';
|
||||
import DynamicEditorialTile from './DynamicEditorialTile';
|
||||
import DynamicFeaturedTiles from './DynamicFeaturedTiles';
|
||||
import DynamicEditorialCarousel from './DynamicEditorialCarousel';
|
||||
import DynamicHeroArticle from './DynamicHeroArticle';
|
||||
import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
|
||||
@@ -21,6 +23,8 @@ import ContactFormSection from './sections/ContactFormSection';
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import { customRegistry } from '../config/customRegistry';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface DynamicPageRendererProps {
|
||||
sections: PageSection[];
|
||||
categories: Category[];
|
||||
@@ -32,6 +36,22 @@ export default function DynamicPageRenderer({
|
||||
categories,
|
||||
categoriesLoading
|
||||
}: DynamicPageRendererProps) {
|
||||
useEffect(() => {
|
||||
const HERO_TYPES = new Set(['HERO', 'HERO_V2', 'HERO_VIDEO']);
|
||||
const firstActiveSection = sections.find(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
|
||||
const isHero = Boolean(firstActiveSection && HERO_TYPES.has(firstActiveSection.type.toUpperCase()));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.heroFirst = isHero ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.heroFirst = 'false';
|
||||
}
|
||||
};
|
||||
}, [sections]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{sections.map((section) => {
|
||||
@@ -45,6 +65,8 @@ export default function DynamicPageRenderer({
|
||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||
case 'HERO_V2':
|
||||
return <DynamicHeroV2 key={section.id} payload={section.payload} />;
|
||||
case 'HERO_VIDEO':
|
||||
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
|
||||
case 'USP_BAR':
|
||||
return <DynamicUspBar key={section.id} payload={section.payload} />;
|
||||
|
||||
@@ -78,6 +100,8 @@ export default function DynamicPageRenderer({
|
||||
return <ScrollReveal key={section.id}><DynamicRichText section={section} /></ScrollReveal>;
|
||||
case 'EDITORIAL_TILE':
|
||||
return <ScrollReveal key={section.id}><DynamicEditorialTile payload={section.payload} /></ScrollReveal>;
|
||||
case 'FEATURED_TILES':
|
||||
return <ScrollReveal key={section.id}><DynamicFeaturedTiles payload={section.payload} /></ScrollReveal>;
|
||||
case 'EDITORIAL_CAROUSEL':
|
||||
return <ScrollReveal key={section.id}><DynamicEditorialCarousel payload={section.payload} /></ScrollReveal>;
|
||||
case 'HERO_ARTICLE':
|
||||
|
||||
@@ -150,7 +150,20 @@ export default function Navbar({ topOffsetClassName = 'top-0', isTransparent = f
|
||||
|
||||
const logoUrl = settings?.logoUrl || ''
|
||||
|
||||
const shouldBeTransparent = isTransparent && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
const [isHeroFirst, setIsHeroFirst] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeroFirst = () => {
|
||||
const isHero = document.documentElement.dataset.heroFirst === 'true';
|
||||
setIsHeroFirst(isHero);
|
||||
};
|
||||
updateHeroFirst();
|
||||
const observer = new MutationObserver(updateHeroFirst);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-hero-first'] });
|
||||
return () => observer.disconnect();
|
||||
}, [location.pathname]);
|
||||
|
||||
const shouldBeTransparent = (isTransparent || isHeroFirst) && !scrolled && !mobileOpen && !searchOpen && !langSheetOpen
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -23,6 +23,9 @@ export default function CustomPage() {
|
||||
(section) => !(section.isActive && section.type.toUpperCase() === 'ANNOUNCEMENT_BAR')
|
||||
);
|
||||
|
||||
const HERO_TYPES = new Set(['HERO', 'HERO_V2', 'HERO_VIDEO']);
|
||||
const hasHeroFirst = contentSections.length > 0 && HERO_TYPES.has(contentSections[0].type.toUpperCase());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
@@ -74,7 +77,7 @@ export default function CustomPage() {
|
||||
categoriesLoading={categoriesLoading}
|
||||
/>
|
||||
))}
|
||||
<Navbar categories={categories} topOffsetClassName={topOffsetClassName} />
|
||||
<Navbar categories={categories} topOffsetClassName={topOffsetClassName} isTransparent={hasHeroFirst} />
|
||||
|
||||
{loading ? (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
@@ -83,7 +86,7 @@ export default function CustomPage() {
|
||||
) : error ? (
|
||||
<NotFoundPage />
|
||||
) : (
|
||||
<div className={`animate-fade-in ${announcementSections.length > 0 ? 'pt-[32px] md:pt-[40px]' : ''}`}>
|
||||
<div className={`animate-fade-in ${announcementSections.length > 0 ? (hasHeroFirst ? 'pt-[32px] md:pt-[40px]' : 'pt-[110px] md:pt-[128px]') : (hasHeroFirst ? '' : 'pt-[70px] md:pt-[80px]')}`}>
|
||||
<DynamicPageRenderer
|
||||
sections={contentSections}
|
||||
categories={categories}
|
||||
|
||||
Reference in new issue
Block a user