ifixkart-storefront/sections/CategoriesSection.tsx

330 lines
11 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { ArrowRight, Layers, ChevronLeft, ChevronRight } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Navigation, A11y } from 'swiper/modules';
import type { Swiper as SwiperType } from 'swiper';
import { catalogService, CategoryResponse } from '../services/api/catalogService';
import { getImageUrl } from '../lib/utils';
import { getSectionTitle } from '../lib/homepageDefaults';
import { Skeleton } from '../components/ui/Skeleton';
import 'swiper/css';
import 'swiper/css/navigation';
interface CategoriesSectionProps {
config?: any;
initialCategories?: CategoryResponse[];
}
/** Category tile image with official URL → product thumb → icon fallback */
function CategoryThumb({
name,
primarySrc,
fallbackSrc,
}: {
name: string;
primarySrc: string;
fallbackSrc?: string;
}) {
const [src, setSrc] = useState(primarySrc || fallbackSrc || '');
const [stage, setStage] = useState<'primary' | 'fallback' | 'empty'>(() =>
primarySrc ? 'primary' : fallbackSrc ? 'fallback' : 'empty'
);
useEffect(() => {
if (primarySrc) {
setSrc(primarySrc);
setStage('primary');
} else if (fallbackSrc) {
setSrc(fallbackSrc);
setStage('fallback');
} else {
setSrc('');
setStage('empty');
}
}, [primarySrc, fallbackSrc]);
const handleError = () => {
if (stage === 'primary' && fallbackSrc) {
setSrc(fallbackSrc);
setStage('fallback');
return;
}
setSrc('');
setStage('empty');
};
if (!src || stage === 'empty') {
return <Layers className="w-10 h-10 text-gray-300 stroke-[1.5]" />;
}
return (
<img
src={src}
alt={name}
loading="lazy"
onError={handleError}
className="max-h-full max-w-full object-contain transition-transform duration-300 group-hover:scale-[1.04]"
/>
);
}
export const CategoriesSection: React.FC<CategoriesSectionProps> = ({
config,
initialCategories,
}) => {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [canSlide, setCanSlide] = useState(false);
const [productThumbs, setProductThumbs] = useState<Record<string, string>>(
{}
);
const swiperRef = useRef<SwiperType | null>(null);
useEffect(() => {
const sortCats = (cats: any[]) =>
[...cats].sort((a, b) => {
const orderA = parseInt(a.sort_order || '0', 10);
const orderB = parseInt(b.sort_order || '0', 10);
return orderA - orderB;
});
if (initialCategories && initialCategories.length > 0) {
setCategories(sortCats(initialCategories));
setLoading(false);
return;
}
setLoading(true);
catalogService
.getCategories()
.then((res) => {
setLoading(false);
const rawCats =
config?.metadata_json?.categories || config?.metadata?.categories;
if (res && res.length > 0) {
setCategories(sortCats(res));
} else if (rawCats && rawCats.length > 0) {
setCategories(sortCats(rawCats));
} else {
setCategories([]);
}
})
.catch((err) => {
console.error('Failed to load categories:', err);
setLoading(false);
const rawCats =
config?.metadata_json?.categories || config?.metadata?.categories;
if (rawCats) {
setCategories(sortCats(rawCats));
} else {
setError(true);
}
});
}, [config, initialCategories]);
// Build category_id → first product thumbnail map (used when category image is missing/broken)
useEffect(() => {
if (categories.length === 0) return;
let cancelled = false;
catalogService
.getProductsPaginated({ limit: 48 })
.then((res) => {
if (cancelled) return;
const map: Record<string, string> = {};
for (const p of res.products || []) {
const cid = p.category_id;
const thumb = getImageUrl(p.thumbnail_url);
if (cid && thumb && !map[cid]) {
map[cid] = thumb;
}
}
setProductThumbs(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [categories]);
const title = getSectionTitle(config, 'featured_categories');
const syncOverflow = (swiper: SwiperType) => {
if (!swiper) return;
setCanSlide(!swiper.isLocked && categories.length > 0);
};
useEffect(() => {
const swiper = swiperRef.current;
if (!swiper) return;
setTimeout(() => {
if (
swiper.params &&
swiper.params.navigation &&
typeof swiper.params.navigation !== 'boolean'
) {
swiper.params.navigation.prevEl = '.cat-swiper-prev';
swiper.params.navigation.nextEl = '.cat-swiper-next';
}
if (swiper.navigation) {
try {
swiper.navigation.destroy();
swiper.navigation.init();
swiper.navigation.update();
} catch (e) {}
}
syncOverflow(swiper);
}, 0);
}, [categories.length]);
return (
<section className="py-8 bg-white font-sans">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
<div className="flex items-center justify-between gap-4 pb-3.5 mb-6 border-b border-[#e5e5e5]">
<h2 className="text-[20px] md:text-[22px] font-bold text-[#222] tracking-tight">
{title}
</h2>
<Link
href="/shop"
className="text-[13px] text-[#333] hover:text-primary transition-colors flex items-center gap-1.5 shrink-0"
>
View All
<ArrowRight size={14} strokeWidth={2} />
</Link>
</div>
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 md:gap-5">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="flex flex-col items-center gap-2">
<Skeleton className="w-full aspect-square rounded-xl" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : error || categories.length === 0 ? (
<div className="bg-[#F8F9FB] rounded-xl p-8 text-center flex flex-col items-center justify-center">
<Layers className="w-8 h-8 text-gray-400 mb-2 stroke-[1.5]" />
<h3 className="text-sm font-bold text-gray-800">No Categories Found</h3>
<p className="text-xs text-gray-500 mt-1">
Categories added in the catalog will appear here.
</p>
</div>
) : (
<div className="relative group/cats px-1">
<button
type="button"
aria-label="Previous categories"
className={`cat-swiper-prev absolute left-0 top-[22%] z-20 -translate-x-1/2 w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center shadow-md transition-opacity duration-200 hover:brightness-95 ${
canSlide
? 'opacity-0 pointer-events-none group-hover/cats:opacity-100 group-hover/cats:pointer-events-auto'
: 'opacity-0 pointer-events-none invisible'
}`}
>
<ChevronLeft size={20} strokeWidth={2.5} />
</button>
<button
type="button"
aria-label="Next categories"
className={`cat-swiper-next absolute right-0 top-[22%] z-20 translate-x-1/2 w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center shadow-md transition-opacity duration-200 hover:brightness-95 ${
canSlide
? 'opacity-0 pointer-events-none group-hover/cats:opacity-100 group-hover/cats:pointer-events-auto'
: 'opacity-0 pointer-events-none invisible'
}`}
>
<ChevronRight size={20} strokeWidth={2.5} />
</button>
<Swiper
modules={[Navigation, A11y]}
onSwiper={(swiper) => {
swiperRef.current = swiper;
setTimeout(() => {
if (
swiper &&
swiper.params &&
swiper.params.navigation &&
typeof swiper.params.navigation !== 'boolean'
) {
swiper.params.navigation.prevEl = '.cat-swiper-prev';
swiper.params.navigation.nextEl = '.cat-swiper-next';
}
if (swiper?.navigation) {
try {
swiper.navigation.destroy();
swiper.navigation.init();
swiper.navigation.update();
} catch (e) {}
}
syncOverflow(swiper);
}, 0);
}}
onResize={syncOverflow}
onBreakpoint={syncOverflow}
onLock={() => setCanSlide(false)}
onUnlock={() => setCanSlide(true)}
navigation={{
prevEl: '.cat-swiper-prev',
nextEl: '.cat-swiper-next',
}}
watchOverflow
spaceBetween={18}
slidesPerView={2}
breakpoints={{
480: { slidesPerView: 3, spaceBetween: 18 },
768: { slidesPerView: 4, spaceBetween: 20 },
1024: { slidesPerView: 5, spaceBetween: 22 },
1280: { slidesPerView: 6, spaceBetween: 24 },
}}
>
{categories.map((cat) => {
const primarySrc = getImageUrl(
cat.image_url || cat.image || cat.icon_url
);
const fallbackSrc =
productThumbs[cat.category_id] ||
productThumbs[cat.id] ||
'';
const count =
cat.product_count !== undefined && cat.product_count !== null
? Number(cat.product_count)
: null;
return (
<SwiperSlide key={cat.category_id || cat.id || cat.slug}>
<Link
href={cat.link || `/shop?category=${cat.slug}`}
className="flex flex-col items-center text-center group"
>
<div className="w-full aspect-square bg-[#F5F5F5] rounded-xl flex items-center justify-center p-5 md:p-6 overflow-hidden transition-colors duration-200 group-hover:bg-[#ececec]">
<CategoryThumb
name={cat.name}
primarySrc={primarySrc}
fallbackSrc={fallbackSrc}
/>
</div>
<h3 className="text-[14px] font-bold text-[#333] mt-3 leading-snug group-hover:text-primary transition-colors">
{cat.name}
</h3>
<span className="text-[12px] text-[#888] mt-0.5">
{count !== null ? `${count} Products` : 'Shop now'}
</span>
</Link>
</SwiperSlide>
);
})}
</Swiper>
</div>
)}
</div>
</section>
);
};