217 lines
8.0 KiB
TypeScript
217 lines
8.0 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { Link, useLocation } from 'react-router-dom'
|
|
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'
|
|
|
|
export default function ShopPage() {
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
const [activeCategory, setActiveCategory] = useState<number | null>(null)
|
|
const [searchInput, setSearchInput] = useState('')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const location = useLocation()
|
|
|
|
// Local progressive rendering
|
|
const [displayCount, setDisplayCount] = useState(24)
|
|
const loaderRef = useRef<HTMLDivElement>(null)
|
|
|
|
const [totalProducts, setTotalProducts] = useState(0)
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [productsLoading, setProductsLoading] = useState(false)
|
|
|
|
// Handle coupon from URL
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(location.search)
|
|
const coupon = params.get('coupon')
|
|
if (coupon) {
|
|
localStorage.setItem('homaroma_active_coupon', coupon)
|
|
// Optionally clean URL
|
|
window.history.replaceState({}, document.title, window.location.pathname)
|
|
}
|
|
}, [location])
|
|
|
|
// Fetch categories
|
|
useEffect(() => {
|
|
fetch(`${API_BASE}/categories`)
|
|
.then((r) => r.json())
|
|
.then((data: Category[]) => {
|
|
setCategories(data)
|
|
setLoading(false)
|
|
})
|
|
.catch(console.error)
|
|
}, [])
|
|
|
|
// Debounce search input
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setSearchQuery(searchInput)
|
|
}, 400)
|
|
return () => clearTimeout(timer)
|
|
}, [searchInput])
|
|
|
|
// Reset scroll and displayCount when category or search changes
|
|
useEffect(() => {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
setDisplayCount(24)
|
|
}, [activeCategory, searchQuery])
|
|
|
|
// Fetch products
|
|
useEffect(() => {
|
|
setProductsLoading(true)
|
|
|
|
const params = new URLSearchParams()
|
|
params.append('limit', '1000') // fetch all matching items locally
|
|
if (activeCategory) params.append('categoryId', activeCategory.toString())
|
|
if (searchQuery.trim()) params.append('search', searchQuery.trim())
|
|
|
|
fetch(`${API_BASE}/products?${params.toString()}`)
|
|
.then((r) => r.json())
|
|
.then((data: { data: Product[], meta?: any }) => {
|
|
setProducts(data.data || [])
|
|
if (data.meta) {
|
|
setTotalProducts(data.meta.total || 0)
|
|
}
|
|
})
|
|
.catch(console.error)
|
|
.finally(() => setProductsLoading(false))
|
|
}, [activeCategory, searchQuery])
|
|
|
|
// Intersection Observer for infinite scroll
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting && products.length > displayCount) {
|
|
setDisplayCount((prev) => Math.min(prev + 12, products.length))
|
|
}
|
|
},
|
|
{ threshold: 0.1, rootMargin: '200px' }
|
|
)
|
|
|
|
if (loaderRef.current) observer.observe(loaderRef.current)
|
|
return () => observer.disconnect()
|
|
}, [products.length, displayCount, productsLoading])
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<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>
|
|
|
|
{/* 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>
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-12 space-y-10">
|
|
{/* Category Filter Tabs */}
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={() => setActiveCategory(null)}
|
|
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
|
|
activeCategory === null
|
|
? 'bg-spa-forest text-white border-spa-forest shadow-md'
|
|
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat.id}
|
|
onClick={() => setActiveCategory(cat.id)}
|
|
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
|
|
activeCategory === cat.id
|
|
? 'bg-spa-forest text-white border-spa-forest shadow-md'
|
|
: 'bg-white text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
|
}`}
|
|
>
|
|
{cat.name}
|
|
{cat._count && (
|
|
<span className={`ml-2 ${activeCategory === cat.id ? 'opacity-80' : 'opacity-60'}`}>({cat._count.products})</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Results count */}
|
|
{(!loading) && (
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-spa-stone font-semibold text-sm">
|
|
{productsLoading ? 'Loading...' : `${totalProducts} products`}
|
|
</p>
|
|
{activeCategory && (
|
|
<Link
|
|
to={`/category/${activeCategory}`}
|
|
className="text-spa-forest text-xs font-bold uppercase tracking-widest hover:underline"
|
|
>
|
|
View Category Page →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Products Grid */}
|
|
{loading || productsLoading ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
|
{Array.from({ length: 9 }).map((_, i) => (
|
|
<div key={i} className="rounded-2xl bg-spa-stone/10 animate-pulse h-96" />
|
|
))}
|
|
</div>
|
|
) : products.length === 0 ? (
|
|
<EmptyState
|
|
title="No products found"
|
|
message="Try adjusting your search or filter"
|
|
actionText="Clear Filters"
|
|
actionClick={() => {
|
|
setActiveCategory(null);
|
|
setSearchInput('');
|
|
setSearchQuery('');
|
|
}}
|
|
/>
|
|
) : (
|
|
<div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
|
{products.slice(0, displayCount).map((p) => (
|
|
<ProductCard key={p.id} product={p} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Invisible target for IntersectionObserver */}
|
|
{displayCount < products.length && (
|
|
<div ref={loaderRef} className="py-12 flex justify-center">
|
|
<div className="w-8 h-8 rounded-full border-2 border-spa-forest border-t-transparent animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Footer categories={categories} />
|
|
</div>
|
|
)
|
|
}
|