Rename ssr to website, website to spa, add spa nginx config

This commit is contained in:
Your Name committed 2026-06-03 09:38:15 +07:00
1 parent fc6bf0218e
commit 8514342c2f
103 files changed
+801 -768

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
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-gradient-to-t from-night-900 via-night-900/60 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-slate-400 uppercase tracking-widest mb-4">
<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-cyan-glow">{category?.name ?? '...'}</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold">
{loading ? (
<div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg" />
) : (
category?.name
)}
</h1>
{!loading && category?._count && (
<p className="text-slate-400 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 ${
cat.id === category?.id
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
: 'glass hover:bg-white/10'
}`}
>
{cat.name}
</Link>
))}
</div>
{error ? (
<div className="text-center py-24 space-y-3">
<p className="text-4xl">⚠️</p>
<p className="text-slate-400">{error}</p>
<Link to="/shop" className="text-cyan-glow 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-white/5 animate-pulse h-96" />
))}
</div>
) : products.length === 0 ? (
<div className="text-center py-24 text-slate-500 space-y-3">
<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-cyan-glow border-t-transparent animate-spin" />
</div>
)}
</div>
)}
</div>
<Footer categories={categories} />
</div>
)
}