330 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|