update carousel

This commit is contained in:
Samandar Turgunboyev
2026-02-10 20:41:53 +05:00
parent 8abd0e448b
commit e270495a17
3 changed files with 404 additions and 70 deletions

View File

@@ -0,0 +1,358 @@
'use client';
import { CategoryResult } from '@/shared/config/api/category/type';
import { product_api } from '@/shared/config/api/product/api';
import { useRouter } from '@/shared/config/i18n/navigation';
import { cn } from '@/shared/lib/utils';
import { Button } from '@/shared/ui/button';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/shared/ui/carousel';
import { ProductCard } from '@/widgets/categories/ui/product-card';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronLeft, ChevronRight, Package } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
interface CategoryAccordionProps {
category: CategoryResult;
}
const CategoryAccordion = memo(function CategoryAccordion({
category,
}: CategoryAccordionProps) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [carousels, setCarousels] = useState<Map<string, CarouselApi>>(
new Map(),
);
const [scrollStates, setScrollStates] = useState<
Map<string, { prev: boolean; next: boolean }>
>(new Map());
// Ref to track if event listeners are already attached
const listenersAttached = useRef<Set<string>>(new Set());
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
// Separate effect to handle carousel listeners
useEffect(() => {
if (carousels.size === 0) return;
const updateScrollState = (subId: string, api: CarouselApi) => {
if (!api) return; // Guard clause
setScrollStates((prev) => {
const newMap = new Map(prev);
newMap.set(subId, {
prev: api.canScrollPrev(),
next: api.canScrollNext(),
});
return newMap;
});
};
const cleanupFunctions: (() => void)[] = [];
carousels.forEach((api, subId) => {
// Skip if api is undefined or listeners already attached
if (!api || listenersAttached.current.has(subId)) return;
const handleUpdate = () => updateScrollState(subId, api);
// Initial update
handleUpdate();
// Attach listeners
api.on('select', handleUpdate);
api.on('reInit', handleUpdate);
// Mark as attached
listenersAttached.current.add(subId);
// Cleanup function
cleanupFunctions.push(() => {
api.off('select', handleUpdate);
api.off('reInit', handleUpdate);
listenersAttached.current.delete(subId);
});
});
return () => {
cleanupFunctions.forEach((cleanup) => cleanup());
};
}, [carousels]);
const handleSetApi = useCallback(
(subId: string, api: CarouselApi | undefined) => {
if (!api) return;
setCarousels((prev) => {
// Only update if this is a new or different API
const existing = prev.get(subId);
if (existing === api) return prev;
const newMap = new Map(prev);
newMap.set(subId, api);
return newMap;
});
},
[],
);
const scrollPrev = useCallback(
(subId: string) => {
const api = carousels.get(subId);
if (api) {
api.scrollPrev();
}
},
[carousels],
);
const scrollNext = useCallback(
(subId: string) => {
const api = carousels.get(subId);
if (api) {
api.scrollNext();
}
},
[carousels],
);
const { data: productsData, isLoading } = useQuery({
queryKey: ['category_products', category.id],
queryFn: async () => {
const results = await Promise.all(
category.product_types.map((sub) =>
product_api.list({ page: 1, page_size: 16, product_type_id: sub.id }),
),
);
return results.map((r, i) => ({
subCategory: category.product_types[i],
products: r.data.results.filter((p) => p.state === 'A'),
}));
},
enabled: isOpen,
staleTime: 5 * 60 * 1000,
});
return (
<section className="custom-container mt-8 mb-6">
{/* Header */}
<div
className={cn(
'group relative overflow-hidden cursor-pointer',
'bg-gradient-to-br from-white via-blue-50 to-indigo-50',
'hover:from-blue-50 hover:via-blue-100 hover:to-indigo-100',
'border border-slate-200 rounded-2xl transition-all duration-500 ease-in-out',
'shadow-sm hover:shadow-lg',
isOpen &&
'border-blue-400 shadow-xl bg-gradient-to-br from-blue-50 to-indigo-50',
)}
onClick={toggle}
>
<div className="flex justify-between items-center p-6 sm:p-8">
<div className="flex items-center gap-4">
<div
className={cn(
'p-3.5 rounded-xl transition-all duration-500 transform',
'bg-white shadow-md',
'group-hover:bg-gradient-to-br group-hover:from-blue-100 group-hover:to-indigo-100 group-hover:shadow-lg group-hover:scale-110',
isOpen &&
'bg-gradient-to-br from-blue-200 to-indigo-200 shadow-lg scale-110',
)}
>
<Package
className={cn(
'size-6 transition-all duration-500',
'text-slate-700',
'group-hover:text-blue-700',
isOpen && 'text-blue-700',
)}
/>
</div>
<div className="flex-1">
<h2
className={cn(
'text-xl sm:text-3xl font-bold transition-all duration-300',
'text-slate-900',
'group-hover:text-blue-800',
isOpen && 'text-blue-800',
)}
>
{category.name}
</h2>
<div className="flex items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-slate-100 group-hover:bg-blue-100 transition-colors">
<span className="text-xs sm:text-sm font-semibold text-slate-700 group-hover:text-blue-700">
{category.product_types.length}
</span>
<span className="text-xs sm:text-sm text-slate-600 group-hover:text-blue-600">
kategoriya
</span>
</span>
</div>
</div>
</div>
<div
className={cn(
'p-3 rounded-full transition-all duration-500 transform',
'bg-white shadow-md',
'group-hover:bg-blue-100 group-hover:shadow-lg group-hover:scale-125',
isOpen && 'bg-blue-200 shadow-lg scale-125',
)}
>
<ChevronDown
className={cn(
'size-6 transition-all duration-500',
'text-slate-700',
'group-hover:text-blue-700',
isOpen ? 'rotate-180 text-blue-700' : 'rotate-0',
)}
/>
</div>
</div>
<div
className={cn(
'h-1.5 bg-gradient-to-r from-blue-600 via-indigo-600 to-purple-600 transition-all duration-500',
'scale-x-0 group-hover:scale-x-100',
isOpen && 'scale-x-100',
)}
/>
</div>
{/* Content */}
<div
className={cn(
'overflow-hidden transition-all duration-700 ease-in-out',
isOpen ? 'max-h-[10000px] opacity-100 mt-6' : 'max-h-0 opacity-0',
)}
>
{isLoading ? (
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, idx) => (
<div key={idx} className="space-y-4">
<div className="h-7 w-56 bg-gradient-to-r from-slate-200 to-slate-100 rounded-lg animate-pulse" />
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 w-44 h-72 bg-gradient-to-br from-slate-100 to-slate-50 rounded-2xl animate-pulse"
/>
))}
</div>
</div>
))}
</div>
) : (
<div className="space-y-10 pb-8">
{productsData?.map((sub) => {
if (!sub.products.length) return null;
const subId = sub.subCategory.id.toString();
const scrollState = scrollStates.get(subId) || {
prev: false,
next: false,
};
return (
<div
key={sub.subCategory.id}
className="bg-gradient-to-br from-white to-slate-50 rounded-2xl border border-slate-200 p-6 sm:p-8 shadow-md hover:shadow-xl transition-all duration-300"
>
{/* Subcategory Header */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<div
className="flex items-center gap-3 group/title cursor-pointer flex-1"
onClick={() =>
router.push(
`/category/${category.id}/${sub.subCategory.id}/`,
)
}
>
<div className="w-1.5 h-8 bg-gradient-to-b from-blue-600 to-indigo-600 rounded-full"></div>
<h3 className="text-xl sm:text-2xl font-bold text-slate-900 group-hover/title:text-blue-700 transition-all duration-300">
{sub.subCategory.name}
</h3>
<div className="p-2 bg-slate-100 rounded-lg group-hover/title:bg-blue-100 transition-all duration-300 ml-auto sm:ml-0">
<ChevronRight className="size-5 text-slate-600 group-hover/title:text-blue-600 group-hover/title:translate-x-1 transition-all duration-300" />
</div>
</div>
<span className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-full border border-blue-200 ml-4">
<span className="text-sm font-bold text-blue-700">
{sub.products.length}
</span>
<span className="text-sm text-blue-600">mahsulot</span>
</span>
</div>
{/* Carousel */}
<div className="relative">
<Carousel
className="w-full"
setApi={(api) => handleSetApi(subId, api)}
>
<CarouselContent className="pr-[12%] sm:pr-0 -ml-3">
{sub.products.map((product) => (
<CarouselItem
key={product.id}
className="pl-3 basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6"
>
<ProductCard product={product} />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{/* Navigation Buttons */}
<Button
onClick={(e) => {
e.stopPropagation();
scrollNext(subId);
}}
className={cn(
'absolute -top-16 right-4 max-lg:hidden text-white shadow-xl transition-all duration-300 transform hover:scale-110 active:scale-95',
scrollState.next
? 'bg-gradient-to-br from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'
: 'bg-slate-300 cursor-not-allowed opacity-40',
)}
disabled={!scrollState.next}
size="icon"
aria-label="next products"
>
<ChevronRight className="size-6" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
scrollPrev(subId);
}}
className={cn(
'absolute -top-16 right-16 max-lg:hidden text-white shadow-xl transition-all duration-300 transform hover:scale-110 active:scale-95',
scrollState.prev
? 'bg-gradient-to-br from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'
: 'bg-slate-300 cursor-not-allowed opacity-40',
)}
disabled={!scrollState.prev}
size="icon"
aria-label="previous products"
>
<ChevronLeft className="size-6" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
);
});
export default CategoryAccordion;

View File

@@ -7,16 +7,25 @@ import { cn } from '@/shared/lib/utils';
import { Button } from '@/shared/ui/button'; import { Button } from '@/shared/ui/button';
import { import {
Carousel, Carousel,
CarouselApi,
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
type CarouselApi,
} from '@/shared/ui/carousel'; } from '@/shared/ui/carousel';
import { ProductCard } from '@/widgets/categories/ui/product-card';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { memo, useEffect, useRef, useState } from 'react';
import { ProductCard } from './product-card';
export function CategoryCarousel({ category }: { category: ProductTypes }) { //////////////////////////
/// CategoryCarousel optimized
//////////////////////////
interface CategoryCarouselProps {
category: ProductTypes;
}
const CategoryCarousel = memo(function CategoryCarousel({
category,
}: CategoryCarouselProps) {
const router = useRouter(); const router = useRouter();
const [api, setApi] = useState<CarouselApi>(); const [api, setApi] = useState<CarouselApi>();
const [canScrollPrev, setCanScrollPrev] = useState(false); const [canScrollPrev, setCanScrollPrev] = useState(false);
@@ -24,76 +33,59 @@ export function CategoryCarousel({ category }: { category: ProductTypes }) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
// Intersection Observer - faqat ko'ringan kategoriyalar uchun API so'rov yuborish // Intersection Observer
useEffect(() => { useEffect(() => {
if (!sectionRef.current) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setIsVisible(true); setIsVisible(true);
observer.disconnect(); // Bir marta ko'ringandan keyin observer ni o'chirish observer.disconnect();
} }
}); });
}, },
{ { rootMargin: '100px', threshold: 0.1 },
rootMargin: '100px', // Sahifa 100px yaqinlashganda yuklash
threshold: 0.1,
},
); );
if (sectionRef.current) { observer.observe(sectionRef.current);
observer.observe(sectionRef.current);
}
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Carousel buttons
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;
const updateButtons = () => { const updateButtons = () => {
setCanScrollPrev(api.canScrollPrev()); setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()); setCanScrollNext(api.canScrollNext());
}; };
updateButtons(); updateButtons();
api.on('select', updateButtons); api.on('select', updateButtons);
api.on('reInit', updateButtons); api.on('reInit', updateButtons);
return () => { return () => {
api.off('select', updateButtons); api.off('select', updateButtons);
api.off('reInit', updateButtons); api.off('reInit', updateButtons);
}; };
}, [api]); }, [api]);
const scrollPrev = () => { const scrollPrev = () => api?.scrollPrev();
if (api) { const scrollNext = () => api?.scrollNext();
api?.scrollPrev();
}
};
const scrollNext = () => { // React Query
if (api) {
api?.scrollNext();
}
};
// Faqat ko'ringanda API so'rov yuborish
const { data: product, isLoading } = useQuery({ const { data: product, isLoading } = useQuery({
queryKey: ['product_list', category.id], queryKey: ['product_list', category.id],
queryFn: () => queryFn: () =>
product_api.list({ product_api.list({
page: 1, page: 1,
page_size: 16, page_size: 16,
product_type_id: category.id, category_id: category.id,
}), }),
select(data) { select: (data) => data.data,
return data.data; enabled: isVisible,
},
enabled: isVisible, // Faqat ko'ringanda ishga tushadi
}); });
// Agar hali ko'rinmagan bo'lsa, bo'sh div qaytarish (scroll uchun joy) // Shartli renderlar
if (!isVisible) { if (!isVisible) {
return ( return (
<section <section
@@ -103,22 +95,12 @@ export function CategoryCarousel({ category }: { category: ProductTypes }) {
); );
} }
// Agar loading bo'lmasa va mahsulotlar bo'lmasa, hech narsa ko'rsatmaymiz if (!isLoading && (!product || product.results.length === 0)) return null;
if (!isLoading && (!product || product.results.length === 0)) {
return null;
}
// Agar loading bo'lmasa va faqat "active" mahsulotlar bo'lmasa ham ko'rsatmaymiz const activeProducts = product?.results.filter((p) => p.state === 'A') ?? [];
if (!isLoading && product) { if (!isLoading && activeProducts.length === 0) return null;
const activeProducts = product.results.filter((p) => p.state === 'A');
if (activeProducts.length === 0) {
return null;
}
}
if (isLoading) { if (isLoading) return null;
return null;
}
return ( return (
<section <section
@@ -141,20 +123,17 @@ export function CategoryCarousel({ category }: { category: ProductTypes }) {
<Carousel className="w-full mt-2" setApi={setApi}> <Carousel className="w-full mt-2" setApi={setApi}>
<CarouselContent className="pr-[12%] sm:pr-0"> <CarouselContent className="pr-[12%] sm:pr-0">
{product && {activeProducts.map((product) => (
!isLoading && <CarouselItem
product.results key={product.id}
.filter((product) => product.state === 'A') className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2"
.map((product) => ( >
<CarouselItem <ProductCard product={product} />
key={product.id} </CarouselItem>
className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2" ))}
>
<ProductCard product={product} />
</CarouselItem>
))}
</CarouselContent> </CarouselContent>
</Carousel> </Carousel>
<Button <Button
onClick={scrollNext} onClick={scrollNext}
className={cn( className={cn(
@@ -177,7 +156,6 @@ export function CategoryCarousel({ category }: { category: ProductTypes }) {
? 'bg-green-600 hover:bg-green-600/70' ? 'bg-green-600 hover:bg-green-600/70'
: 'bg-green-600/50 cursor-not-allowed', : 'bg-green-600/50 cursor-not-allowed',
)} )}
aria-label="prev images"
disabled={!canScrollPrev} disabled={!canScrollPrev}
size="icon" size="icon"
> >
@@ -185,4 +163,6 @@ export function CategoryCarousel({ category }: { category: ProductTypes }) {
</Button> </Button>
</section> </section>
); );
} });
export default CategoryCarousel;

View File

@@ -16,7 +16,7 @@ import {
type CarouselApi, type CarouselApi,
} from '@/shared/ui/carousel'; } from '@/shared/ui/carousel';
import { Skeleton } from '@/shared/ui/skeleton'; import { Skeleton } from '@/shared/ui/skeleton';
import { CategoryCarousel } from '@/widgets/categories/ui/category-carousel'; import CategoryCarousel from '@/widgets/categories/ui/category-carousel';
import { ProductCard } from '@/widgets/categories/ui/product-card'; import { ProductCard } from '@/widgets/categories/ui/product-card';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
@@ -241,7 +241,7 @@ const Welcome = () => {
</CarouselItem> </CarouselItem>
))} ))}
</CarouselContent> </CarouselContent>
</Carousel>{' '} </Carousel>
<Button <Button
onClick={scrollNextPro} onClick={scrollNextPro}
className={cn( className={cn(
@@ -273,11 +273,7 @@ const Welcome = () => {
</section> </section>
{category && {category &&
category.map((e) => category.map((e) => <CategoryCarousel category={e} key={e.id} />)}
e.product_types.map((c) => (
<CategoryCarousel category={c} key={c.id} />
)),
)}
</> </>
); );
}; };