x
This commit is contained in:
1 parent
d325cd8763
commit
c6bf2f70ac
45 files changed
+5195
-338
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay } from 'swiper/modules';
|
||||
import type { AnnouncementBarSectionPayload } from '../types';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
|
||||
interface DynamicAnnouncementBarProps {
|
||||
payload: AnnouncementBarSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
|
||||
const { slides = [], backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="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"
|
||||
style={{ backgroundColor, color: textColor }}
|
||||
>
|
||||
<Swiper
|
||||
modules={[Autoplay]}
|
||||
autoplay={{ delay: speed, disableOnInteraction: false }}
|
||||
loop={slides.length > 1}
|
||||
className="w-full select-none"
|
||||
>
|
||||
{slides.map((slide, idx) => (
|
||||
<SwiperSlide key={idx} className="flex items-center justify-center">
|
||||
<span className="inline-block truncate max-w-full">{slide}</span>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BannerSectionPayload } from '@homaroma/types';
|
||||
|
||||
interface DynamicBannerProps {
|
||||
payload: BannerSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
||||
const { imageUrl, linkUrl, title, description, buttonText } = payload;
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</Wrapper>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
|
||||
|
||||
interface DynamicCategoriesProps {
|
||||
payload: CategoriesSectionPayload;
|
||||
categories: Category[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default function DynamicCategories({ payload, categories, loading }: DynamicCategoriesProps) {
|
||||
const { title, subtitle, categoryIds } = payload;
|
||||
|
||||
// Filter categories if categoryIds list is provided
|
||||
const displayedCategories = React.useMemo(() => {
|
||||
if (categoryIds && categoryIds.length > 0) {
|
||||
return categories.filter((cat) => categoryIds.includes(cat.id));
|
||||
}
|
||||
return categories;
|
||||
}, [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">
|
||||
{title || "Our Collections"}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-gray-500 max-w-2xl mx-auto">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-80 brutal-box bg-gray-50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-6">
|
||||
{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"
|
||||
>
|
||||
<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(cat.name)}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={cat.name}
|
||||
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">
|
||||
{cat.name}
|
||||
</span>
|
||||
{cat._count && (
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
{cat._count.products} Products
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EditorialCarouselSectionPayload } from '@homaroma/types';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
interface DynamicEditorialCarouselProps {
|
||||
payload: EditorialCarouselSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) {
|
||||
const { title, slides = [] } = payload;
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
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-spa-gold font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
{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]}
|
||||
spaceBetween={30}
|
||||
slidesPerView={1}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 2 },
|
||||
1024: { slidesPerView: 3 }
|
||||
}}
|
||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-gold' }}
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
className="w-full"
|
||||
>
|
||||
{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="aspect-[4/3] overflow-hidden relative">
|
||||
<img
|
||||
src={slide.imageUrl}
|
||||
alt={slide.title}
|
||||
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="absolute inset-0 bg-gradient-to-t from-[#1F3D2D]/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">
|
||||
{slide.category}
|
||||
</span>
|
||||
<h3 className="text-xl font-light font-serif 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">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{slide.buttonText && slide.buttonLink && (
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
to={slide.buttonLink}
|
||||
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-spa-gold hover:text-white transition-colors group/link"
|
||||
>
|
||||
{slide.buttonText}
|
||||
<span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EditorialTileSectionPayload } from '@homaroma/types';
|
||||
|
||||
interface DynamicEditorialTileProps {
|
||||
payload: EditorialTileSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicEditorialTile({ payload }: DynamicEditorialTileProps) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
imageUrl,
|
||||
imagePosition = 'left',
|
||||
buttonText,
|
||||
buttonLink,
|
||||
backgroundColor = '#FDFDF9', // Default to spa-light
|
||||
textColor = '#1F3D2D' // Default to spa-forest
|
||||
} = payload;
|
||||
|
||||
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-spa-gold font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="w-full overflow-hidden py-16 md:py-24 px-6 md:px-16"
|
||||
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 ${
|
||||
imagePosition === 'left' ? 'md:order-1' : 'md:order-2'
|
||||
}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
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';
|
||||
}}
|
||||
/>
|
||||
{/* 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 ${
|
||||
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">
|
||||
{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">
|
||||
{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"
|
||||
>
|
||||
{buttonText}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { HeroSectionPayload } from '@homaroma/types';
|
||||
|
||||
interface DynamicHeroProps {
|
||||
payload: HeroSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
const { tagline, title, description, imageUrl, buttonText, buttonLink } = payload;
|
||||
|
||||
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-spa-gold font-medium italic">
|
||||
{accent}
|
||||
</span>
|
||||
{rest}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={imageUrl || "/images/hero.png"}
|
||||
className="w-full h-full object-cover scale-[1.02]"
|
||||
alt="Hero Background"
|
||||
onError={(e) => {
|
||||
(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>
|
||||
|
||||
<div className="relative z-10 text-center space-y-6 px-8 animate-fade-in max-w-4xl w-full mx-auto mt-16 md:mt-0">
|
||||
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
|
||||
{tagline || "Vibrant Elegance Collection"}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
|
||||
{renderTitle(title) || (
|
||||
<>
|
||||
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-spa-light/90 max-w-2xl mx-auto text-lg md:text-xl font-light pt-4 pb-8 drop-shadow-md">
|
||||
{description}
|
||||
</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"
|
||||
>
|
||||
{buttonText || "Explore Collection"}
|
||||
</Link>
|
||||
<a
|
||||
href="#categories"
|
||||
className="px-8 py-4 text-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
|
||||
>
|
||||
Learn More
|
||||
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { API_BASE, type NewsletterSectionPayload } from '../types';
|
||||
|
||||
interface DynamicNewsletterProps {
|
||||
payload: NewsletterSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
buttonText = 'Subscribe',
|
||||
placeholderText = 'Enter your email address',
|
||||
showPrivacyCheckbox = true,
|
||||
privacyText = 'I agree to the privacy policy.'
|
||||
} = payload;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [agreed, setAgreed] = useState(!showPrivacyCheckbox);
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (showPrivacyCheckbox && !agreed) {
|
||||
alert('Please agree to the privacy policy.');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('submitting');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages/newsletter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setEmail('');
|
||||
} else {
|
||||
const errData = await res.json();
|
||||
setErrorMessage(errData.error || 'Failed to subscribe.');
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Newsletter submission error:', err);
|
||||
setErrorMessage('Network error. Please try again.');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 px-6 bg-spa-forest text-white relative overflow-hidden">
|
||||
{/* Background decorations for luxury aesthetic */}
|
||||
<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="space-y-3">
|
||||
<h2 className="text-3xl md:text-5xl font-serif font-light tracking-wide text-spa-gold">
|
||||
{title || "Begin Your Wellness Journey"}
|
||||
</h2>
|
||||
<p className="text-spa-light/80 text-sm md:text-base font-light max-w-xl mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status === 'success' ? (
|
||||
<div className="p-6 bg-white/5 border border-spa-gold/30 rounded-2xl animate-pop-in space-y-2">
|
||||
<h4 className="text-spa-gold font-semibold text-lg">Thank You for Subscribing</h4>
|
||||
<p className="text-xs text-spa-light/75">You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
disabled={status === 'submitting'}
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
{status === 'submitting' ? 'Submitting...' : buttonText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-rose-400 text-xs text-left px-4">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{showPrivacyCheckbox && (
|
||||
<label className="flex items-start gap-3 text-[11px] text-spa-light/60 text-left px-4 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="mt-0.5 accent-spa-gold"
|
||||
/>
|
||||
<span>{privacyText}</span>
|
||||
</label>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PageSection, Category } from '@homaroma/types';
|
||||
import DynamicHero from './DynamicHero';
|
||||
import DynamicCategories from './DynamicCategories';
|
||||
import DynamicProducts from './DynamicProducts';
|
||||
import DynamicBanner from './DynamicBanner';
|
||||
import DynamicText from './DynamicText';
|
||||
import DynamicAnnouncementBar from './DynamicAnnouncementBar';
|
||||
import DynamicNewsletter from './DynamicNewsletter';
|
||||
import DynamicEditorialTile from './DynamicEditorialTile';
|
||||
import DynamicEditorialCarousel from './DynamicEditorialCarousel';
|
||||
|
||||
interface DynamicPageRendererProps {
|
||||
sections: PageSection[];
|
||||
categories: Category[];
|
||||
categoriesLoading: boolean;
|
||||
}
|
||||
|
||||
export default function DynamicPageRenderer({
|
||||
sections,
|
||||
categories,
|
||||
categoriesLoading
|
||||
}: DynamicPageRendererProps) {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{sections.map((section) => {
|
||||
if (!section.isActive) return null;
|
||||
|
||||
switch (section.type.toUpperCase()) {
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
return <DynamicAnnouncementBar key={section.id} payload={section.payload} />;
|
||||
case 'NEWSLETTER':
|
||||
return <DynamicNewsletter key={section.id} payload={section.payload} />;
|
||||
case 'HERO':
|
||||
return <DynamicHero key={section.id} payload={section.payload} />;
|
||||
case 'CATEGORIES':
|
||||
return (
|
||||
<DynamicCategories
|
||||
key={section.id}
|
||||
payload={section.payload}
|
||||
categories={categories}
|
||||
loading={categoriesLoading}
|
||||
/>
|
||||
);
|
||||
case 'PRODUCTS':
|
||||
return <DynamicProducts key={section.id} payload={section.payload} />;
|
||||
case 'BANNER':
|
||||
return <DynamicBanner key={section.id} payload={section.payload} />;
|
||||
case 'TEXT':
|
||||
return <DynamicText key={section.id} payload={section.payload} />;
|
||||
case 'EDITORIAL_TILE':
|
||||
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
|
||||
case 'EDITORIAL_CAROUSEL':
|
||||
return <DynamicEditorialCarousel key={section.id} payload={section.payload} />;
|
||||
default:
|
||||
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
import ProductCard from './ProductCard';
|
||||
import type { Product, ProductsSectionPayload } from '@homaroma/types';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
|
||||
interface DynamicProductsProps {
|
||||
payload: ProductsSectionPayload & { products?: Product[] };
|
||||
}
|
||||
|
||||
export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
const { title, subtitle, products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-pop-black">
|
||||
{title || "Featured Picks"}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-gray-500 mt-2">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
||||
>
|
||||
View All Collection →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No products found in this section
|
||||
</div>
|
||||
) : layout === 'carousel' ? (
|
||||
<div className="relative select-none pb-12">
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination]}
|
||||
spaceBetween={24}
|
||||
slidesPerView={1}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 2 },
|
||||
1024: { slidesPerView: 3 }
|
||||
}}
|
||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-forest' }}
|
||||
autoplay={{ delay: 6000, disableOnInteraction: false }}
|
||||
className="w-full"
|
||||
>
|
||||
{products.map((p) => (
|
||||
<SwiperSlide key={p.id} className="h-auto">
|
||||
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{products.map((p) => (
|
||||
<div key={p.id}>
|
||||
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TextSectionPayload } from '@homaroma/types';
|
||||
|
||||
interface DynamicTextProps {
|
||||
payload: TextSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicText({ payload }: DynamicTextProps) {
|
||||
const { title, content, textAlign = 'left' } = payload;
|
||||
|
||||
const alignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[textAlign] || 'text-left';
|
||||
|
||||
return (
|
||||
<section className="py-16 px-6 bg-white relative">
|
||||
<div className={`max-w-4xl mx-auto space-y-6 ${alignClass}`}>
|
||||
{title && (
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-spa-forest font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
className="text-gray-600 leading-relaxed text-base md:text-lg font-light whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,51 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Product } from '../types'
|
||||
import { getCategoryImage, formatPrice } from '../types'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { useWishlistStore } from '../store/wishlistStore'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import type { Product } from '../types';
|
||||
import { getCategoryImage, formatPrice } from '../types';
|
||||
import { Heart, ShoppingBag } from 'lucide-react';
|
||||
import { useWishlistStore } from '../store/wishlistStore';
|
||||
import { useCartStore } from '../store/cartStore';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
product: Product;
|
||||
enableQuickShop?: boolean;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const { addItem, removeItem, isInWishlist } = useWishlistStore()
|
||||
const isWished = isInWishlist(product.id)
|
||||
export default function ProductCard({ product, enableQuickShop = false }: ProductCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
||||
const { addToCart } = useCartStore();
|
||||
const isWished = isInWishlist(product.id);
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
|
||||
const handleWishlistClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault() // prevent navigating to product detail
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isWished) {
|
||||
removeItem(product.id)
|
||||
removeItem(product.id);
|
||||
} else {
|
||||
addItem(product)
|
||||
addItem(product);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/products/${product.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/products/${product.id}`}
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
{/* Image Area */}
|
||||
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
||||
<img
|
||||
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
onError={(e) => {
|
||||
(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">
|
||||
@@ -38,8 +53,9 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`}
|
||||
@@ -47,8 +63,9 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="mb-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">
|
||||
{product.scent.name}
|
||||
</h3>
|
||||
@@ -57,15 +74,70 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<span className="text-rose-500 font-black text-sm md:text-lg">
|
||||
{formatPrice(product.variants[0]?.price)}
|
||||
</span>
|
||||
<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">
|
||||
View Details →
|
||||
</span>
|
||||
{/* Quick Shop Variant Swatches */}
|
||||
{enableQuickShop && product.variants && product.variants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2 mb-1">
|
||||
{product.variants.map((variant) => {
|
||||
const isSelected = selectedVariant?.id === variant.id;
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedVariant(variant);
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-[10px] font-bold tracking-wider uppercase border 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'
|
||||
}`}
|
||||
>
|
||||
{variant.size}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Area */}
|
||||
<div className="mt-auto pt-3 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-rose-500 font-black text-sm 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">
|
||||
View Details →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enableQuickShop && selectedVariant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: selectedVariant.id,
|
||||
quantity: 1,
|
||||
price: selectedVariant.price || 0,
|
||||
productName: product.scent.name,
|
||||
variantSize: selectedVariant.size,
|
||||
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
|
||||
});
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
Add to Cart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
||||
const [rating, setRating] = useState(5)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [filterRating, setFilterRating] = useState<number | null>(null)
|
||||
const [visibleCount, setVisibleCount] = useState(3)
|
||||
|
||||
const fetchReviews = () => {
|
||||
fetch(`${API_BASE}/reviews/product/${productId}`)
|
||||
@@ -68,6 +70,12 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
||||
? (reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length).toFixed(1)
|
||||
: '0.0'
|
||||
|
||||
const filteredReviews = filterRating
|
||||
? reviews.filter(r => r.rating === filterRating)
|
||||
: reviews
|
||||
|
||||
const visibleReviews = filteredReviews.slice(0, visibleCount)
|
||||
|
||||
return (
|
||||
<div className="pt-16 border-t border-spa-stone/10 space-y-12">
|
||||
<div className="flex flex-col md:flex-row gap-12 items-start">
|
||||
@@ -118,12 +126,47 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Filter Bar */}
|
||||
{reviews.length > 0 && !loading && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
<button
|
||||
onClick={() => { setFilterRating(null); setVisibleCount(3); }}
|
||||
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||
filterRating === null
|
||||
? 'bg-spa-stone text-white border-spa-stone'
|
||||
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
||||
}`}
|
||||
>
|
||||
All ({reviews.length})
|
||||
</button>
|
||||
{[5, 4, 3, 2, 1].map(star => {
|
||||
const count = reviews.filter(r => r.rating === star).length;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={`filter-${star}`}
|
||||
onClick={() => { setFilterRating(star); setVisibleCount(3); }}
|
||||
className={`flex-shrink-0 flex items-center gap-1 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||
filterRating === star
|
||||
? 'bg-spa-stone text-white border-spa-stone'
|
||||
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
||||
}`}
|
||||
>
|
||||
{star} <Star className={`w-3 h-3 ${filterRating === star ? 'fill-white text-white' : 'fill-orange-400 text-orange-400'}`} /> ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-spa-stone animate-pulse text-sm">Loading reviews...</p>
|
||||
) : reviews.length === 0 ? (
|
||||
<p className="text-spa-stone/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
||||
) : visibleReviews.length === 0 ? (
|
||||
<p className="text-spa-stone/60 text-sm italic">No reviews found for this rating.</p>
|
||||
) : (
|
||||
reviews.map(review => (
|
||||
visibleReviews.map(review => (
|
||||
<div key={review.id} className="border-b border-spa-stone/10 pb-6 last:border-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{review.customer?.pictureUrl ? (
|
||||
@@ -147,6 +190,17 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{visibleCount < filteredReviews.length && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
onClick={() => setVisibleCount(prev => prev + 5)}
|
||||
className="px-6 py-2.5 border border-spa-stone/20 text-spa-stone text-xs font-bold tracking-widest uppercase rounded-full hover:border-spa-stone hover:text-pop-black transition-all bg-white"
|
||||
>
|
||||
Show More Reviews
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user