This commit is contained in:
Your Name committed 2026-06-07 17:17:01 +07:00
1 parent d325cd8763
commit c6bf2f70ac
45 files changed
+5195 -338

No files matched your search

+2
View File
@@ -18,6 +18,7 @@ import CouponsPage from './pages/CouponsPage'
import OrderSuccessPage from './pages/OrderSuccessPage'
import TrackOrderPage from './pages/TrackOrderPage'
import WishlistPage from './pages/WishlistPage'
import CustomPage from './pages/CustomPage'
import CartDrawer from './components/CartDrawer'
function App() {
@@ -50,6 +51,7 @@ function App() {
<Route path="/article/:slug" element={<ArticleReaderPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/pages/:slug" element={<CustomPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
@@ -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>
);
}
+54
View File
@@ -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">&rarr;</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>
);
}
+77
View File
@@ -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">&rarr;</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 &rarr;
</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>
);
}
+31
View File
@@ -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>
);
}
+99 -27
View File
@@ -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 &rarr;
</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 &rarr;
</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>
);
}
+55 -1
View File
@@ -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>
+81 -33
View File
@@ -3,7 +3,8 @@ import { useParams, Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import { API_BASE, getCategoryImage, type Category, type Product } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
export default function CategoryPage() {
const { id } = useParams<{ id: string }>()
@@ -11,7 +12,8 @@ export default function CategoryPage() {
const [categories, setCategories] = useState<Category[]>([])
const [category, setCategory] = useState<Category | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
// Local progressive rendering
const [displayCount, setDisplayCount] = useState(24)
const loaderRef = useRef<HTMLDivElement>(null)
@@ -26,6 +28,22 @@ export default function CategoryPage() {
.catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/category-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Category dynamic layout sections not loaded:', err.message);
});
}, [])
// Reset state when category changes
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -73,45 +91,67 @@ export default function CategoryPage() {
return () => observer.disconnect()
}, [products.length, displayCount, loading])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Navbar categories={categories} />
{/* Hero Banner for category */}
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={getCategoryImage(category?.name ?? '')}
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
alt={category?.name}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="absolute inset-0 bg-night-900/40" />
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
<div className="max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-spa-sage/80 uppercase tracking-widest mb-4 font-semibold">
<Link to="/" className="hover:text-white transition-colors">Home</Link>
<span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<span>/</span>
<span className="text-white">{category?.name ?? '...'}</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white">
{loading ? (
<div className="h-10 w-48 bg-white/20 animate-pulse rounded-lg" />
) : (
category?.name
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="relative h-64 md:h-80 overflow-hidden">
<img
src={getCategoryImage(category?.name ?? '')}
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
alt={category?.name}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<div className="absolute inset-0 bg-night-900/40" />
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
<div className="max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-spa-sage/80 uppercase tracking-widest mb-4 font-semibold">
<Link to="/" className="hover:text-white transition-colors">Home</Link>
<span>/</span>
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
<span>/</span>
<span className="text-white">{category?.name ?? '...'}</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white">
{loading ? (
<div className="h-10 w-48 bg-white/20 animate-pulse rounded-lg" />
) : (
category?.name
)}
</h1>
{!loading && category?._count && (
<p className="text-spa-sage mt-2">
{category._count.products} products available
</p>
)}
</h1>
{!loading && category?._count && (
<p className="text-spa-sage mt-2">
{category._count.products} products available
</p>
)}
</div>
</div>
</div>
</div>
)}
{/* Products Grid */}
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
@@ -169,6 +209,14 @@ export default function CategoryPage() {
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Footer categories={categories} />
</div>
)
+56 -8
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
type FormState = { name: string; email: string; subject: string; message: string }
type Status = 'idle' | 'sending' | 'sent' | 'error'
@@ -12,11 +13,28 @@ export default function ContactPage() {
const [categories, setCategories] = useState<Category[]>([])
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
const [status, setStatus] = useState<Status>('idle')
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/contact-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Contact dynamic layout sections not loaded:', err.message);
});
}, [])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
}
@@ -30,18 +48,40 @@ export default function ContactPage() {
setForm({ name: '', email: '', subject: '', message: '' })
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
{/* Header */}
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
<h1 className="text-5xl font-bold">Contact Us</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Have a question, custom request, or partnership idea? We'd love to hear from you.
</p>
</div>
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</div>
) : (
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
<h1 className="text-5xl font-bold">Contact Us</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Have a question, custom request, or partnership idea? We'd love to hear from you.
</p>
</div>
)}
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
{/* Contact Info */}
@@ -177,6 +217,14 @@ export default function ContactPage() {
</div>
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+61 -9
View File
@@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
import { Ticket, Copy, CheckCircle2, ArrowRight } from 'lucide-react'
interface Coupon {
@@ -23,6 +24,28 @@ export default function CouponsPage() {
const [coupons, setCoupons] = useState<Coupon[]>([])
const [loading, setLoading] = useState(true)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`)
.then(res => res.json())
.then(setCategories)
.catch(console.error)
fetch(`${API_BASE}/pages/coupons-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Coupons dynamic layout sections not loaded:', err.message);
});
}, [])
useEffect(() => {
fetch(`${API_BASE}/categories`)
@@ -61,19 +84,40 @@ export default function CouponsPage() {
navigate('/shop')
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
<div className="text-center space-y-4 mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-pop-black">
Exclusive Offers
</h1>
<p className="text-spa-stone max-w-2xl mx-auto">
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
</p>
</div>
{/* Header */}
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4 mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-pop-black">
Exclusive Offers
</h1>
<p className="text-spa-stone max-w-2xl mx-auto">
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
</p>
</div>
)}
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -154,6 +198,14 @@ export default function CouponsPage() {
)}
</main>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+93
View File
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import DynamicPageRenderer from '../components/DynamicPageRenderer';
import { API_BASE, type Category, type PageSection } from '../types';
export default function CustomPage() {
const { slug } = useParams<{ slug: string }>();
const [categories, setCategories] = useState<Category[]>([]);
const [sections, setSections] = useState<PageSection[]>([]);
const [loading, setLoading] = useState(true);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchCategories = async () => {
try {
const res = await fetch(`${API_BASE}/categories`);
const cats: Category[] = await res.json();
setCategories(cats);
} catch (err) {
console.error('Categories error:', err);
} finally {
setCategoriesLoading(false);
}
};
const fetchPageLayout = async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/pages/${slug}`);
if (!res.ok) {
setError('Page not found');
return;
}
const pageData = await res.json();
if (pageData && !pageData.error) {
setSections(pageData.sections || []);
} else {
setError(pageData.error || 'Page not found');
}
} catch (err) {
console.error('Page layout error:', err);
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
};
fetchCategories();
fetchPageLayout();
}, [slug]);
return (
<div className="min-h-screen bg-[#FDFDF9] text-[#1F3D2D]">
<Navbar categories={categories} />
{loading ? (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-spa-forest border-t-transparent rounded-full animate-spin"></div>
</div>
) : error ? (
<div className="min-h-[60vh] flex flex-col items-center justify-center space-y-6 px-6 text-center">
<h2 className="text-3xl font-serif font-light tracking-wide text-spa-forest">
Sensory Page Not Found
</h2>
<p className="text-spa-stone/70 text-sm max-w-md font-light">
The page you are looking for does not exist or may have been moved.
</p>
<Link
to="/"
className="px-8 py-3 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-full font-bold text-xs uppercase tracking-widest transition-all"
>
Return to Home
</Link>
</div>
) : (
<div className="animate-fade-in">
<DynamicPageRenderer
sections={sections}
categories={categories}
categoriesLoading={categoriesLoading}
/>
</div>
)}
<Footer categories={categories} />
</div>
);
}
+56 -8
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
const FAQS = [
{
@@ -46,27 +47,66 @@ export default function FaqPage() {
const [categories, setCategories] = useState<Category[]>([])
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
const [activeCategory, setActiveCategory] = useState<string>('Products')
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/faq-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('FAQ dynamic layout sections not loaded:', err.message);
});
}, [])
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
const activeFaqs = FAQS.find(f => f.category === activeCategory)
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
{/* Header */}
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Everything you need to know about HomAroma products, shipping, and care.
</p>
</div>
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</div>
) : (
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Everything you need to know about HomAroma products, shipping, and care.
</p>
</div>
)}
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
{/* Category tabs */}
@@ -133,6 +173,14 @@ export default function FaqPage() {
</div>
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+35 -167
View File
@@ -1,193 +1,61 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import { API_BASE, getCategoryImage, type Category, type Product, type Hero } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
export default function HomePage() {
const [categories, setCategories] = useState<Category[]>([])
const [featuredProducts, setFeaturedProducts] = useState<Product[]>([])
const [hero, setHero] = useState<Hero | null>(null)
const [sections, setSections] = useState<PageSection[]>([])
const [loading, setLoading] = useState(true)
const [categoriesLoading, setCategoriesLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
const fetchCategories = async () => {
try {
const [catRes, prodRes, heroRes] = await Promise.all([
fetch(`${API_BASE}/categories`),
fetch(`${API_BASE}/products?categoryId=1`),
fetch(`${API_BASE}/hero`),
])
const cats: Category[] = await catRes.json()
const prodsJson = await prodRes.json()
const heroText = await heroRes.text()
const heroData: Hero | null = heroText ? JSON.parse(heroText) : null
const prods: Product[] = prodsJson.data || []
const res = await fetch(`${API_BASE}/categories`)
const cats: Category[] = await res.json()
setCategories(cats)
setFeaturedProducts(prods.slice(0, 3))
setHero(heroData)
} catch (err) {
console.error('API error:', err)
console.error('Categories error:', err)
} finally {
setCategoriesLoading(false)
}
}
const fetchPageLayout = async () => {
try {
const res = await fetch(`${API_BASE}/pages/home`)
const pageData = await res.json()
if (pageData && pageData.sections) {
setSections(pageData.sections)
}
} catch (err) {
console.error('Page layout error:', err)
} finally {
setLoading(false)
}
}
fetchData()
fetchCategories()
fetchPageLayout()
}, [])
return (
<div className="min-h-screen bg-pop-bg">
<Navbar categories={categories} />
{/* Hero Section */}
<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={hero?.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-night-900/40"></div>
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/10 to-night-900/30"></div>
{loading ? (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-pop-pink border-t-transparent rounded-full animate-spin"></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">
{hero?.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">
{hero ? (
hero.title.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>
})
) : (
<>
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">
{hero?.description || "Crafting premium sensory experiences that transform your space into a sanctuary of peace."}
</p>
<div className="flex flex-col sm:flex-row gap-6 items-center justify-center">
{/* White solid primary button */}
<Link
to={hero?.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"
>
{hero?.buttonText || "Explore Collection"}
</Link>
{/* Outline secondary 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">&rarr;</span>
</a>
</div>
</div>
</section>
{/* Categories Showcase */}
<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">
Our Collections
</h2>
<p className="text-gray-500 max-w-2xl mx-auto">Discover the perfect scent for every moment</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">
{categories.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={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>
{/* Featured Products */}
<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">
Featured Picks
</h2>
</div>
<Link
to="/shop"
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
>
View All Collection &rarr;
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="brutal-box bg-white animate-pulse h-96" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{featuredProducts.map((p) => (
<div key={p.id}>
<ProductCard product={p} />
</div>
))}
</div>
)}
</div>
</section>
) : (
<DynamicPageRenderer
sections={sections}
categories={categories}
categoriesLoading={categoriesLoading}
/>
)}
<Footer categories={categories} />
</div>
+28 -3
View File
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category, type PageSection } from '../types'
import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react'
export default function OrderSuccessPage() {
@@ -14,17 +15,22 @@ export default function OrderSuccessPage() {
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [copied, setCopied] = useState(false)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
fetch(`${API_BASE}/settings`).then(r => r.json()),
fetch(`${API_BASE}/categories`).then(r => r.json())
fetch(`${API_BASE}/categories`).then(r => r.json()),
fetch(`${API_BASE}/pages/order-success-layout`).then(r => r.ok ? r.json() : null).catch(() => null)
])
.then(([orderData, settingsData, catData]) => {
.then(([orderData, settingsData, catData, pageData]) => {
setOrder(orderData)
setSettings(settingsData)
setCategories(catData)
if (pageData && pageData.sections) {
setCmsSections(pageData.sections)
}
})
.catch(console.error)
.finally(() => setLoading(false))
@@ -92,8 +98,19 @@ export default function OrderSuccessPage() {
)
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in text-pop-black">
@@ -221,6 +238,14 @@ export default function OrderSuccessPage() {
</main>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+38 -1
View File
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection } from '../types'
import { useCartStore } from '../store/cartStore'
export default function ProductDetailPage() {
@@ -16,6 +17,7 @@ export default function ProductDetailPage() {
const [selectedVariant, setSelectedVariant] = useState<number>(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { addToCart } = useCartStore()
@@ -26,6 +28,22 @@ export default function ProductDetailPage() {
.catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/product-detail-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Product detail dynamic layout sections not loaded:', err.message);
});
}, [])
useEffect(() => {
if (!id) return
setLoading(true)
@@ -100,8 +118,19 @@ export default function ProductDetailPage() {
})
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR');
return (
<div className="min-h-screen pb-24 md:pb-0 bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Navbar categories={categories} />
<div className="max-w-7xl mx-auto px-6 pt-24 md:pt-32 pb-24 space-y-12 md:space-y-24">
@@ -261,6 +290,14 @@ export default function ProductDetailPage() {
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Footer categories={categories} />
{/* Mobile Sticky Add to Cart Bar */}
+56 -8
View File
@@ -2,29 +2,69 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, type Category, type Article } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type Article, type PageSection } from '../types'
export default function ScentGuidePage() {
const [categories, setCategories] = useState<Category[]>([])
const [articles, setArticles] = useState<Article[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
fetch(`${API_BASE}/articles?publishedOnly=true`).then(r => r.json()).then(setArticles).catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/scent-guide-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Scent guide dynamic layout sections not loaded:', err.message);
});
}, [])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen flex flex-col">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
{/* Header */}
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white border-b border-spa-stone/10">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
<h1 className="text-5xl font-bold">Scent Guide</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Explore our collection of articles, guides, and sensory knowledge.
</p>
</div>
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
</div>
) : (
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
<h1 className="text-5xl font-bold">Scent Guide</h1>
<p className="text-slate-400 max-w-lg mx-auto">
Explore our collection of articles, guides, and sensory knowledge.
</p>
</div>
)}
<div className="flex-1 max-w-7xl mx-auto px-6 py-16 w-full">
{articles.length === 0 ? (
@@ -62,6 +102,14 @@ export default function ScentGuidePage() {
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+88 -40
View File
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category, type Product } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type Product, type PageSection } from '../types'
export default function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams()
@@ -15,11 +16,28 @@ export default function SearchPage() {
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/search-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Search dynamic layout sections not loaded:', err.message);
});
}, [])
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setProducts([]); setSearched(false); return }
setLoading(true)
@@ -46,54 +64,76 @@ export default function SearchPage() {
setSearchParams(input.trim() ? { q: input.trim() } : {})
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Navbar categories={categories} />
{/* Search Header */}
<div className="pt-32 pb-16 px-6 bg-spa-light border-b border-spa-stone/10">
<div className="max-w-3xl mx-auto space-y-6 text-center">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
Discover
</p>
<h1 className="text-5xl font-bold text-pop-black">Search Products</h1>
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="pt-32 pb-16 px-6 bg-spa-light border-b border-spa-stone/10">
<div className="max-w-3xl mx-auto space-y-6 text-center">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
Discover
</p>
<h1 className="text-5xl font-bold text-pop-black">Search Products</h1>
<form onSubmit={handleSubmit} className="relative">
<div className="flex bg-white border border-spa-stone/20 rounded-2xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-5 h-5 text-spa-stone m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search by scent name, category..."
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-pop-black placeholder-spa-stone/60"
autoFocus
/>
<button
type="submit"
className="bg-spa-forest text-white px-8 font-bold text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors shrink-0"
>
Search
</button>
<form onSubmit={handleSubmit} className="relative">
<div className="flex bg-white border border-spa-stone/20 rounded-2xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-5 h-5 text-spa-stone m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search by scent name, category..."
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-pop-black placeholder-spa-stone/60"
autoFocus
/>
<button
type="submit"
className="bg-spa-forest text-white px-8 font-bold text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors shrink-0"
>
Search
</button>
</div>
</form>
{/* Quick category links */}
<div className="flex flex-wrap justify-center gap-2 pt-2">
{categories.map((cat) => (
<Link
key={cat.id}
to={`/search?q=${encodeURIComponent(cat.name)}`}
className="bg-white border border-spa-stone/20 px-4 py-1.5 rounded-full text-xs font-medium text-spa-stone hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all duration-300"
>
{cat.name}
</Link>
))}
</div>
</form>
{/* Quick category links */}
<div className="flex flex-wrap justify-center gap-2 pt-2">
{categories.map((cat) => (
<Link
key={cat.id}
to={`/search?q=${encodeURIComponent(cat.name)}`}
className="bg-white border border-spa-stone/20 px-4 py-1.5 rounded-full text-xs font-medium text-spa-stone hover:border-spa-forest hover:text-spa-forest shadow-sm transition-all duration-300"
>
{cat.name}
</Link>
))}
</div>
</div>
</div>
)}
{/* Results */}
<div className="max-w-7xl mx-auto px-6 py-16 space-y-8">
@@ -131,6 +171,14 @@ export default function SearchPage() {
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Footer categories={categories} />
</div>
)
+79 -12
View File
@@ -4,7 +4,8 @@ import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import EmptyState from '../components/EmptyState'
import { API_BASE, type Category, type Product } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type Product, type PageSection } from '../types'
export default function ShopPage() {
const [categories, setCategories] = useState<Category[]>([])
@@ -22,6 +23,7 @@ export default function ShopPage() {
const [loading, setLoading] = useState(true)
const [productsLoading, setProductsLoading] = useState(false)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
// Handle coupon from URL
useEffect(() => {
@@ -45,6 +47,23 @@ export default function ShopPage() {
.catch(console.error)
}, [])
// Fetch CMS sections for shop-layout
useEffect(() => {
fetch(`${API_BASE}/pages/shop-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Shop dynamic layout sections not loaded:', err.message);
});
}, [])
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
@@ -95,22 +114,62 @@ export default function ShopPage() {
return () => observer.disconnect()
}, [products.length, displayCount, productsLoading])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Navbar categories={categories} />
{/* Page Header */}
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-spa-light">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
All Products
</p>
<h1 className="text-5xl font-bold tracking-tight text-pop-black">The Collection</h1>
<p className="text-spa-stone max-w-md mx-auto">
Browse our full range of premium aroma products — crafted to elevate every space.
</p>
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={loading}
/>
</div>
) : (
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-spa-light">
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-bold opacity-80">
All Products
</p>
<h1 className="text-5xl font-bold tracking-tight text-pop-black">The Collection</h1>
<p className="text-spa-stone max-w-md mx-auto">
Browse our full range of premium aroma products — crafted to elevate every space.
</p>
{/* Search */}
<div className="max-w-md mx-auto mt-6">
{/* Search */}
<div className="max-w-md mx-auto mt-6">
<div className="flex bg-white border border-spa-stone/20 rounded-xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-4 h-4 text-spa-stone m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search by scent or category..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="bg-transparent border-none outline-none text-sm p-2 w-full text-pop-black placeholder-spa-stone/60"
/>
</div>
</div>
</div>
)}
{/* Render Search bar below Hero block if Hero is configured */}
{heroSections.length > 0 && (
<div className="max-w-md mx-auto mt-8 px-6">
<div className="flex bg-white border border-spa-stone/20 rounded-xl overflow-hidden shadow-sm focus-within:border-spa-forest focus-within:ring-1 focus-within:ring-spa-forest transition-all">
<svg className="w-4 h-4 text-spa-stone m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -124,7 +183,7 @@ export default function ShopPage() {
/>
</div>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
{/* Category Filter Tabs */}
@@ -210,6 +269,14 @@ export default function ShopPage() {
)}
</div>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={loading}
/>
)}
<Footer categories={categories} />
</div>
)
+53 -7
View File
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
import { Search, MapPin, Package, Loader2, ChevronRight, Clock } from 'lucide-react'
import { useAuthStore } from '../store/authStore'
@@ -26,6 +27,23 @@ export default function TrackOrderPage() {
const [phone, setPhone] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/pages/track-order-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Track Order dynamic layout sections not loaded:', err.message);
});
}, [])
useEffect(() => {
fetch(`${API_BASE}/categories`)
@@ -86,20 +104,40 @@ export default function TrackOrderPage() {
}
}
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen flex flex-col bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
<main className="flex-1 max-w-4xl w-full mx-auto px-6 flex flex-col justify-center pt-32 pb-24 space-y-12 animate-fade-in">
{/* Header */}
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mx-auto border border-spa-stone/10">
<MapPin className="w-8 h-8 text-spa-forest" />
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mx-auto border border-spa-stone/10">
<MapPin className="w-8 h-8 text-spa-forest" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Track Your Status</h1>
<p className="text-spa-stone">Enter your order details below to check shipping status or upload payment slips.</p>
</div>
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Track Your Status</h1>
<p className="text-spa-stone">Enter your order details below to check shipping status or upload payment slips.</p>
</div>
)}
<div className="grid md:grid-cols-2 gap-12">
@@ -234,6 +272,14 @@ export default function TrackOrderPage() {
</main>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)
+55 -9
View File
@@ -3,13 +3,15 @@ import { Link } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import { API_BASE, type Category } from '../types'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, type Category, type PageSection } from '../types'
import { useWishlistStore } from '../store/wishlistStore'
import { Heart } from 'lucide-react'
export default function WishlistPage() {
const [categories, setCategories] = useState<Category[]>([])
const { items } = useWishlistStore()
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
useEffect(() => {
fetch(`${API_BASE}/categories`)
@@ -18,22 +20,58 @@ export default function WishlistPage() {
.catch(console.error)
}, [])
useEffect(() => {
fetch(`${API_BASE}/pages/wishlist-layout`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
}
})
.catch((err) => {
console.log('Wishlist dynamic layout sections not loaded:', err.message);
});
}, [])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
return (
<div className="min-h-screen flex flex-col bg-pop-bg">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Navbar categories={categories} />
<main className="flex-1 max-w-7xl w-full mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in">
{/* Header */}
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto border border-rose-100">
<Heart className="w-8 h-8 text-rose-500 fill-rose-500" />
{heroSections.length > 0 ? (
<DynamicPageRenderer
sections={heroSections}
categories={categories}
categoriesLoading={false}
/>
) : (
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto border border-rose-100">
<Heart className="w-8 h-8 text-rose-500 fill-rose-500" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Your Wishlist</h1>
<p className="text-spa-stone max-w-lg mx-auto">
Items you've loved. Keep them here until you're ready to make them yours.
</p>
</div>
<h1 className="text-4xl font-bold tracking-tight text-pop-black">Your Wishlist</h1>
<p className="text-spa-stone max-w-lg mx-auto">
Items you've loved. Keep them here until you're ready to make them yours.
</p>
</div>
)}
{items.length === 0 ? (
<div className="text-center py-24 bg-white shadow-sm border border-spa-stone/10 rounded-3xl max-w-2xl mx-auto space-y-6">
@@ -61,6 +99,14 @@ export default function WishlistPage() {
</main>
{bottomSections.length > 0 && (
<DynamicPageRenderer
sections={bottomSections}
categories={categories}
categoriesLoading={false}
/>
)}
<Footer categories={categories} />
</div>
)