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 ProductCard from '../components/ProductCard'
import DynamicPageRenderer from '../components/DynamicPageRenderer' import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types' import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
import { getLocalizedField } from '../utils/localization'
export default function CategoryPage() { export default function CategoryPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const { t, i18n } = useTranslation()
const lang = i18n.language
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [category, setCategory] = useState<Category | null>(null) const [category, setCategory] = useState<Category | null>(null)
const [products, setProducts] = useState<Product[]>([]) const [products, setProducts] = useState<Product[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([]) const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
// Local progressive rendering // Local progressive rendering
const [displayCount, setDisplayCount] = useState(24) const [displayCount, setDisplayCount] = useState(24)
@@ -23,28 +25,34 @@ export default function CategoryPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Fetch all categories for navigation and pills
useEffect(() => { useEffect(() => {
fetch(`${API_BASE}/categories?lang=${i18n.language}`) fetch(`${API_BASE}/categories?lang=${lang}`)
.then((r) => r.json()) .then((r) => r.json())
.then(setCategories) .then((data: Category[]) => {
if (Array.isArray(data)) {
setCategories(data)
}
})
.catch(console.error) .catch(console.error)
}, [i18n.language]) }, [lang])
// Fetch CMS sections for category-layout
useEffect(() => { useEffect(() => {
fetch(`${API_BASE}/pages/category-layout?lang=${i18n.language}`) fetch(`${API_BASE}/pages/category-layout?lang=${lang}`)
.then((r) => { .then((r) => {
if (!r.ok) throw new Error('Page not found'); if (!r.ok) throw new Error('Page not found')
return r.json(); return r.json()
}) })
.then((data) => { .then((data) => {
if (data && data.sections) { if (data && data.sections) {
setCmsSections(data.sections); setCmsSections(data.sections)
} }
}) })
.catch((err) => { .catch((err) => {
console.log('Category dynamic layout sections not loaded:', err.message); console.log('Category dynamic layout sections not loaded:', err.message)
}); })
}, [i18n.language]) }, [lang])
// Reset state when category changes // Reset state when category changes
useEffect(() => { useEffect(() => {
@@ -52,28 +60,22 @@ export default function CategoryPage() {
setDisplayCount(24) setDisplayCount(24)
}, [id]) }, [id])
// Fetch single category and its products
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
setLoading(true) setLoading(true)
setError(null) setError(null)
Promise.all([ fetch(`${API_BASE}/categories/${id}/products?limit=1000&lang=${lang}`)
// Fetch up to 1000 items (essentially all) in one go .then(async (catRes) => {
fetch(`${API_BASE}/categories/${id}/products?limit=1000&lang=${i18n.language}`),
fetch(`${API_BASE}/categories?lang=${i18n.language}`),
])
.then(async ([catRes, allCatsRes]) => {
if (!catRes.ok) throw new Error('Category not found') if (!catRes.ok) throw new Error('Category not found')
const catPaginated = await catRes.json() const catPaginated = await catRes.json()
const allCats: Category[] = await allCatsRes.json()
setCategory(catPaginated.category) setCategory(catPaginated.category)
setProducts(catPaginated.data || []) setProducts(catPaginated.data || [])
setCategories(allCats)
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [id, i18n.language]) }, [id, lang])
// Intersection Observer for infinite scroll // Intersection Observer for infinite scroll
useEffect(() => { useEffect(() => {
@@ -83,7 +85,7 @@ export default function CategoryPage() {
setDisplayCount((prev) => Math.min(prev + 12, products.length)) 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) { if (loaderRef.current) {
@@ -93,12 +95,15 @@ export default function CategoryPage() {
return () => observer.disconnect() return () => observer.disconnect()
}, [products.length, displayCount, loading]) }, [products.length, displayCount, loading])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR'); 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 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 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 ( return (
<div className="min-h-screen"> <div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && ( {announcementSections.length > 0 && (
<DynamicPageRenderer <DynamicPageRenderer
sections={announcementSections} sections={announcementSections}
@@ -109,7 +114,7 @@ export default function CategoryPage() {
<Navbar categories={categories} /> <Navbar categories={categories} />
{/* Hero Banner for category */} {/* Hero Banner for Category */}
{heroSections.length > 0 ? ( {heroSections.length > 0 ? (
<div className="pt-20"> <div className="pt-20">
<DynamicPageRenderer <DynamicPageRenderer
@@ -119,83 +124,98 @@ export default function CategoryPage() {
/> />
</div> </div>
) : ( ) : (
<div className="bg-spa-stone/5 border-b border-spa-stone/10 pt-32 pb-16 px-6"> <section className="relative overflow-hidden bg-night-900/60 border-b border-white/5">
<div className="max-w-7xl mx-auto flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16"> <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"> <div className="flex-1 text-center md:text-left">
{/* Breadcrumb */} {/* 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"> <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="hover:text-spa-forest transition-colors">Home</Link> <Link to="/" className="transition hover:text-cyan-glow">{t('nav.home', 'Home')}</Link>
<span>/</span> <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>/</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> </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 ? ( {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> </h1>
{!loading && category?._count && (
<p className="text-spa-stone mt-4"> {!loading && category?._count !== undefined && (
{category._count.products} products available <p className="text-slate-400 text-sm mt-3">
{category._count.products} {t('categories.itemCount', 'products available')}
</p> </p>
)} )}
</div> </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 <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" 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' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/> />
</div> </div>
</div>
</div>
)} )}
{/* Products Grid */} </div>
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10"> </section>
)}
{/* Products & Category Navigation */}
<div className="mx-auto max-w-7xl space-y-10 px-6 py-12">
{/* Other Category Pills */} {/* Other Category Pills */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-2.5">
{categories.map((cat) => ( {categories.map((cat) => {
const catItemName = getLocalizedField(cat.name, lang)
const isActive = String(cat.id) === String(id)
return (
<Link <Link
key={cat.id} key={cat.id}
to={`/category/${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 ${ className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all duration-200 border cursor-pointer ${
cat.id === category?.id isActive
? 'bg-spa-forest text-spa-light border-spa-forest shadow-md' ? 'bg-cyan-glow text-night-950 border-cyan-glow shadow-md shadow-cyan-glow/20 font-black'
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm' : '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> </Link>
))} )
})}
</div> </div>
{error ? ( {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-4xl">⚠️</p>
<p className="text-spa-stone font-medium">{error}</p> <p className="text-slate-300 font-medium">{error}</p>
<Link to="/shop" className="text-spa-forest font-bold text-sm hover:underline"> <Link to="/shop" className="text-cyan-glow font-bold text-sm hover:underline inline-block mt-2">
Back to Shop {t('shop.backToShop', 'Back to Shop')}
</Link> </Link>
</div> </div>
) : loading ? ( ) : loading ? (
<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">
{Array.from({ length: 9 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-spa-stone/10 animate-pulse h-96" /> <div key={i} className="rounded-2xl bg-white/5 animate-pulse h-80 border border-white/5" />
))} ))}
</div> </div>
) : products.length === 0 ? ( ) : products.length === 0 ? (
<div className="text-center py-24 text-spa-stone/60 space-y-3 font-medium"> <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-4xl">📦</p>
<p>No products in this category yet.</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> <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) => ( {products.slice(0, displayCount).map((p) => (
<ProductCard key={p.id} product={p} /> <ProductCard key={p.id} product={p} />
))} ))}
@@ -204,7 +224,7 @@ export default function CategoryPage() {
{/* Invisible target for IntersectionObserver */} {/* Invisible target for IntersectionObserver */}
{displayCount < products.length && ( {displayCount < products.length && (
<div ref={loaderRef} className="py-12 flex justify-center"> <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>
)} )}
</div> </div>