This commit is contained in:
Your Name committed 2026-07-28 00:13:57 -04:00
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':
+14 -1
View File
@@ -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 (
<>
+5 -2
View File
@@ -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}