x
This commit is contained in:
1 parent
57022d46af
commit
e9f6e7201a
1 file changed
+86
-66
@@ -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">
|
||||||
|
← {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>
|
||||||
|
|||||||
Reference in new issue
Block a user