This commit is contained in:
Your Name committed 2026-09-29 17:03:36 +07:00
1 parent 57022d46af
commit e9f6e7201a
1 file changed
+86 -66
+86 -66
View File
@@ -6,15 +6,17 @@ import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
import { getLocalizedField } from '../utils/localization'
export default function CategoryPage() {
const { id } = useParams<{ id: string }>()
const { t, i18n } = useTranslation()
const lang = i18n.language
const [categories, setCategories] = useState<Category[]>([])
const [category, setCategory] = useState<Category | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
// Local progressive rendering
const [displayCount, setDisplayCount] = useState(24)
@@ -23,28 +25,34 @@ export default function CategoryPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch all categories for navigation and pills
useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
fetch(`${API_BASE}/categories?lang=${lang}`)
.then((r) => r.json())
.then(setCategories)
.then((data: Category[]) => {
if (Array.isArray(data)) {
setCategories(data)
}
})
.catch(console.error)
}, [i18n.language])
}, [lang])
// Fetch CMS sections for category-layout
useEffect(() => {
fetch(`${API_BASE}/pages/category-layout?lang=${i18n.language}`)
fetch(`${API_BASE}/pages/category-layout?lang=${lang}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
if (!r.ok) throw new Error('Page not found')
return r.json()
})
.then((data) => {
if (data && data.sections) {
setCmsSections(data.sections);
setCmsSections(data.sections)
}
})
.catch((err) => {
console.log('Category dynamic layout sections not loaded:', err.message);
});
}, [i18n.language])
console.log('Category dynamic layout sections not loaded:', err.message)
})
}, [lang])
// Reset state when category changes
useEffect(() => {
@@ -52,28 +60,22 @@ export default function CategoryPage() {
setDisplayCount(24)
}, [id])
// Fetch single category and its products
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&lang=${i18n.language}`),
fetch(`${API_BASE}/categories?lang=${i18n.language}`),
])
.then(async ([catRes, allCatsRes]) => {
fetch(`${API_BASE}/categories/${id}/products?limit=1000&lang=${lang}`)
.then(async (catRes) => {
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, i18n.language])
}, [id, lang])
// Intersection Observer for infinite scroll
useEffect(() => {
@@ -83,7 +85,7 @@ export default function CategoryPage() {
setDisplayCount((prev) => Math.min(prev + 12, products.length))
}
},
{ threshold: 0.1, rootMargin: '200px' } // Pre-load slightly before seeing the loader
{ threshold: 0.1, rootMargin: '200px' }
)
if (loaderRef.current) {
@@ -93,12 +95,15 @@ 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');
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')
const categoryName = getLocalizedField(category?.name, lang)
const categoryImageUrl = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : '')
return (
<div className="min-h-screen">
<div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && (
<DynamicPageRenderer
sections={announcementSections}
@@ -109,7 +114,7 @@ export default function CategoryPage() {
<Navbar categories={categories} />
{/* Hero Banner for category */}
{/* Hero Banner for Category */}
{heroSections.length > 0 ? (
<div className="pt-20">
<DynamicPageRenderer
@@ -119,83 +124,98 @@ export default function CategoryPage() {
/>
</div>
) : (
<div className="bg-spa-stone/5 border-b border-spa-stone/10 pt-32 pb-16 px-6">
<div className="max-w-7xl mx-auto flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16">
<section className="relative overflow-hidden bg-night-900/60 border-b border-white/5">
<div className="relative mx-auto max-w-7xl px-6 pt-32 pb-12 md:pt-36 md:pb-20 flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16">
<div className="flex-1 text-center md:text-left">
{/* Breadcrumb */}
<div className="flex items-center justify-center md:justify-start gap-2 text-xs text-spa-stone/60 uppercase tracking-widest mb-4 font-semibold">
<Link to="/" className="hover:text-spa-forest transition-colors">Home</Link>
<div className="mb-4 flex items-center justify-center md:justify-start gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
<Link to="/" className="transition hover:text-cyan-glow">{t('nav.home', 'Home')}</Link>
<span>/</span>
<Link to="/shop" className="hover:text-spa-forest transition-colors">Shop</Link>
<Link to="/shop" className="transition hover:text-cyan-glow">{t('nav.shop', 'Shop')}</Link>
<span>/</span>
<span className="text-spa-forest">{typeof category?.name === 'string' ? category.name : (category?.name as any)?.[localStorage.getItem('app_lang') || 'en'] || (category?.name as any)?.en || '...'}</span>
<span className="text-cyan-glow font-bold">{categoryName || '...'}</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-spa-forest">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-white tracking-tight">
{loading ? (
<div className="h-10 w-48 bg-spa-stone/20 animate-pulse rounded-lg mx-auto md:mx-0" />
<div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg mx-auto md:mx-0" />
) : (
typeof category?.name === 'string' ? category.name : (category?.name as any)?.[i18n.language] || (category?.name as any)?.en || ''
categoryName
)}
</h1>
{!loading && category?._count && (
<p className="text-spa-stone mt-4">
{category._count.products} products available
{!loading && category?._count !== undefined && (
<p className="text-slate-400 text-sm mt-3">
{category._count.products} {t('categories.itemCount', 'products available')}
</p>
)}
</div>
<div className="relative w-48 h-48 md:w-72 md:h-72 shrink-0 rounded-3xl overflow-hidden shadow-lg border border-spa-stone/10">
{categoryImageUrl && (
<div className="relative w-48 h-48 md:w-64 md:h-64 shrink-0 rounded-3xl overflow-hidden shadow-2xl border border-white/10 bg-night-950">
<img
src={getCategoryImage(typeof category?.name === 'string' ? category.name : (category?.name as any)?.en || '')}
src={categoryImageUrl}
alt={categoryName}
className="absolute inset-0 w-full h-full object-cover"
alt={typeof category?.name === 'string' ? category.name : (category?.name as any)?.[i18n.language] || (category?.name as any)?.en || ''}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
</div>
</div>
)}
{/* Products Grid */}
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
</div>
</section>
)}
{/* Products & Category Navigation */}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12">
{/* Other Category Pills */}
<div className="flex flex-wrap gap-3">
{categories.map((cat) => (
<div className="flex flex-wrap gap-2.5">
{categories.map((cat) => {
const catItemName = getLocalizedField(cat.name, lang)
const isActive = String(cat.id) === String(id)
return (
<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-spa-light border-spa-forest shadow-md'
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all duration-200 border cursor-pointer ${
isActive
? 'bg-cyan-glow text-night-950 border-cyan-glow shadow-md shadow-cyan-glow/20 font-black'
: 'bg-night-900/80 text-slate-400 border-white/10 hover:border-cyan-glow/40 hover:text-white'
}`}
>
{typeof cat.name === 'string' ? cat.name : (cat.name as any)?.[localStorage.getItem('app_lang') || 'en'] || (cat.name as any)?.en || ''}
{catItemName}
</Link>
))}
)
})}
</div>
{error ? (
<div className="text-center py-24 space-y-3">
<div className="text-center py-20 space-y-3 bg-night-900/40 rounded-2xl border border-white/5">
<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
<p className="text-slate-300 font-medium">{error}</p>
<Link to="/shop" className="text-cyan-glow font-bold text-sm hover:underline inline-block mt-2">
{t('shop.backToShop', '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 className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-80 border border-white/5" />
))}
</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 className="text-center py-20 bg-night-900/40 rounded-2xl border border-white/5 space-y-3">
<p className="text-4xl">📦</p>
<p className="text-slate-300 font-medium text-base">
{t('category.noProducts', 'No products in this category yet.')}
</p>
<Link to="/shop" className="text-cyan-glow font-bold text-xs uppercase tracking-wider hover:underline inline-block pt-2">
&larr; {t('shop.exploreAllProducts', 'Explore All Products')}
</Link>
</div>
) : (
<div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
{products.slice(0, displayCount).map((p) => (
<ProductCard key={p.id} product={p} />
))}
@@ -204,7 +224,7 @@ export default function CategoryPage() {
{/* 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 className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
</div>
)}
</div>