176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
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'
|
|
|
|
export default function CategoryPage() {
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [category, setCategory] = useState<Category | null>(null)
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
|
|
// Local progressive rendering
|
|
const [displayCount, setDisplayCount] = useState(24)
|
|
const loaderRef = useRef<HTMLDivElement>(null)
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetch(`${API_BASE}/categories`)
|
|
.then((r) => r.json())
|
|
.then(setCategories)
|
|
.catch(console.error)
|
|
}, [])
|
|
|
|
// Reset state when category changes
|
|
useEffect(() => {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
setDisplayCount(24)
|
|
}, [id])
|
|
|
|
useEffect(() => {
|
|
if (!id) return
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
Promise.all([
|
|
// Fetch up to 1000 items (essentially all) in one go
|
|
fetch(`${API_BASE}/categories/${id}/products?limit=1000`),
|
|
fetch(`${API_BASE}/categories`),
|
|
])
|
|
.then(async ([catRes, allCatsRes]) => {
|
|
if (!catRes.ok) throw new Error('Category not found')
|
|
const catPaginated = await catRes.json()
|
|
const allCats: Category[] = await allCatsRes.json()
|
|
|
|
setCategory(catPaginated.category)
|
|
setProducts(catPaginated.data || [])
|
|
setCategories(allCats)
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false))
|
|
}, [id])
|
|
|
|
// 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' } // Pre-load slightly before seeing the loader
|
|
)
|
|
|
|
if (loaderRef.current) {
|
|
observer.observe(loaderRef.current)
|
|
}
|
|
|
|
return () => observer.disconnect()
|
|
}, [products.length, displayCount, loading])
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<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
|
|
)}
|
|
</h1>
|
|
{!loading && category?._count && (
|
|
<p className="text-spa-sage mt-2">
|
|
{category._count.products} products available
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Products Grid */}
|
|
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
|
|
{/* Other Category Pills */}
|
|
<div className="flex flex-wrap gap-3">
|
|
{categories.map((cat) => (
|
|
<Link
|
|
key={cat.id}
|
|
to={`/category/${cat.id}`}
|
|
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 border ${
|
|
cat.id === category?.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}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="text-center py-24 space-y-3">
|
|
<p className="text-4xl">⚠️</p>
|
|
<p className="text-spa-stone font-medium">{error}</p>
|
|
<Link to="/shop" className="text-spa-forest font-bold text-sm hover:underline">
|
|
Back to Shop
|
|
</Link>
|
|
</div>
|
|
) : loading ? (
|
|
<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 ? (
|
|
<div className="text-center py-24 text-spa-stone/60 space-y-3 font-medium">
|
|
<p className="text-4xl">🌿</p>
|
|
<p>No products in this category yet.</p>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|