x
This commit is contained in:
1 parent
d325cd8763
commit
c6bf2f70ac
45 files changed
+5195
-338
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">→</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 →
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user