first commit

This commit is contained in:
Jelaletdin12
2026-02-01 20:55:57 +05:00
commit b8c871750a
128 changed files with 23114 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
"use client";
import Image, { type StaticImageData } from "next/image";
import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import "swiper/css";
type CarouselItem = {
title: string;
image: StaticImageData | string;
url?: string | null;
};
export default function HeroCarousel({ items }: { items: CarouselItem[] }) {
return (
<section className="rounded-2xl overflow-hidden">
<Swiper
modules={[Autoplay]}
slidesPerView={1}
loop
autoplay={{ delay: 3000, disableOnInteraction: false }}
>
{items.map((item, i) => (
<SwiperSlide key={i}>
{item.url ? (
<Link
href={item.url}
className="block relative w-full h-[200px] sm:h-[300px] md:h-[496px]"
>
<Image
src={item.image}
alt={item.title}
fill
className="object-cover"
priority={i === 0}
/>
</Link>
) : (
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[496px]">
<Image
src={item.image}
alt={item.title}
fill
className="object-cover"
priority={i === 0}
/>
</div>
)}
</SwiperSlide>
))}
</Swiper>
</section>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import type { Category } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
categories: Category[] | undefined;
isLoading: boolean;
isError: boolean;
locale: string;
title: string;
};
export default function CategoryGrid({
categories,
isLoading,
isError,
locale,
title,
}: Props) {
if (isError) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<p className="text-red-600">
Failed to load categories. Please try again.
</p>
</section>
);
}
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full h-36 rounded-lg" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
</section>
);
}
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{categories?.map((cat) => (
<Link
key={cat.id}
href={`/${locale}/category/${cat.slug}?category_id=${cat.id}`}
>
<Card className="hover:shadow-md border-none shadow-none p-0 gap-2 transition-all cursor-pointer">
<div className="relative w-full h-36 overflow-hidden rounded-lg">
<Image
src={
cat.media[0]?.thumbnail || cat.media?.[0]?.images_400x400
}
alt={cat.name}
fill
className="object-contain"
/>
</div>
<CardContent className="py-2">
<p className="text-sm font-medium text-gray-800 truncate text-center">
{cat.name}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import HeroCarousel from "./Carousel";
import CategoryGrid from "./CategoryGrid";
import CollectionSection from "./ProductGrid";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCategories,
useCarousels,
useCollections,
useFavorites,
} from "@/lib/hooks";
export default function HomePage() {
const locale = useLocale();
const t = useTranslations("common");
const [visibleCount, setVisibleCount] = useState(10);
const {
data: categories,
isLoading: categoriesLoading,
isError: categoriesError,
} = useCategories();
const { data: carousels, isLoading: carouselsLoading } = useCarousels();
const {
data: collections,
isLoading: collectionsLoading,
isError: collectionsError,
} = useCollections();
useFavorites();
const loadMore = () => {
if (collections && visibleCount < collections.length) {
setVisibleCount((prev) => Math.min(prev + 10, collections.length));
}
};
const carouselItems =
carousels?.map((c) => ({
title: c.title || "",
image: c.image || c.thumbnail,
url: c.link || null,
})) || [];
const visibleCollections = collections?.slice(0, visibleCount) || [];
const hasMore = collections ? visibleCount < collections.length : false;
return (
<div className="px-2 md:px-4 lg:px-6 pt-4 pb-12 space-y-8 max-w-[1504px] mx-auto">
{carouselsLoading ? (
<section className=" bg-white rounded-2xl overflow-hidden">
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[496px]" />
</section>
) : (
carouselItems.length > 0 && <HeroCarousel items={carouselItems} />
)}
<CategoryGrid
categories={categories}
isLoading={categoriesLoading}
isError={categoriesError}
locale={locale}
title={t("categories")}
/>
{collectionsError ? (
<section className="bg-white rounded-2xl shadow-sm p-6">
<p className="text-red-600">
Failed to load collections. Please try again.
</p>
</section>
) : collectionsLoading ? (
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, i) => (
<section key={i} className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, j) => (
<div key={j} className="space-y-2">
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
</section>
))}
</div>
) : (
<InfiniteScroll
dataLength={visibleCollections.length}
next={loadMore}
hasMore={hasMore}
loader={
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent" />
<p className="text-gray-500 mt-2">{t("loading")}</p>
</div>
}
endMessage={
<div className="text-center py-8">
<p className="text-gray-600"> {t("all_collections_loaded")}</p>
</div>
}
scrollThreshold={0.8}
>
<div className="space-y-8">
{visibleCollections.map((collection) => (
<CollectionSection
key={collection.id}
collection={collection}
locale={locale}
/>
))}
</div>
</InfiniteScroll>
)}
</div>
);
}

View File

@@ -0,0 +1,452 @@
"use client";
import { useState, useEffect, useRef, useCallback, MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { Heart, ShoppingCart, Plus, Minus, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToggleFavorite, useIsFavorite } from "@/lib/hooks";
import {
useAddToCart,
useUpdateCartItemQuantity,
useCart,
} from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
type ProductCardProps = {
id: number;
name: string;
price: number | null;
struct_price_text: string;
discount?: number | null;
discount_text?: string | null;
images: string[];
labels?: { text: string; bg_color: string }[];
price_color?: string;
height?: number;
width?: number;
button?: boolean;
stock?: number;
};
export default function ProductCard({
id,
name,
price,
struct_price_text,
images,
labels = [],
price_color = "#005bff",
height = 360,
width = 280,
button = false,
stock,
}: ProductCardProps) {
const router = useRouter();
const t = useTranslations();
const { isFavorite, isLoading: isFavoriteLoading } = useIsFavorite(id);
const { mutate: toggleFavorite, isPending: isFavoriteToggling } =
useToggleFavorite();
const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const { data: cartData, refetch: refetchCart } = useCart();
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [localQuantity, setLocalQuantity] = useState(1);
const [isSyncing, setIsSyncing] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const autoplayRef = useRef<NodeJS.Timeout | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef<boolean>(false);
const pendingQuantityRef = useRef<number | null>(null);
const hasMultipleImages = images.length > 1;
const cartItem = cartData?.data?.find((item: any) => item.product?.id === id);
const isInCart = !!cartItem;
const isOutOfStock = stock === 0;
const availableStock = stock || 999;
useEffect(() => {
if (!api) return;
setCurrent(api.selectedScrollSnap());
const onSelect = () => setCurrent(api.selectedScrollSnap());
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api]);
useEffect(() => {
if (!api || !hasMultipleImages) return;
autoplayRef.current = setInterval(() => {
api.canScrollNext() ? api.scrollNext() : api.scrollTo(0);
}, 3000);
return () => {
if (autoplayRef.current) clearInterval(autoplayRef.current);
};
}, [api, hasMultipleImages]);
useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]);
const syncToServer = useCallback(
async (quantity: number) => {
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
try {
await updateCartMutation.mutateAsync({ productId: id, quantity });
await refetchCart();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServer(nextQuantity), 100);
}
} catch (error) {
console.error("Sync failed:", error);
setLocalQuantity(cartItem?.product_quantity || 1);
toast.error("Failed to update quantity", {
description: "Please try again",
});
} finally {
isRequestInFlightRef.current = false;
setIsSyncing(false);
}
},
[id, updateCartMutation, cartItem, refetchCart]
);
useEffect(() => {
if (!isInCart || localQuantity === (cartItem?.product_quantity || 1))
return;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
syncToServer(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [localQuantity, isInCart, cartItem, syncToServer]);
const handleFavorite = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
toggleFavorite(
{ productId: id, isFavorite },
{
onSuccess: (data) =>
toast.success(
data.wasAdded ? t("added_to_favorites") : t("removed_from_favorites")
),
onError: () => toast.error("Error. Try again"),
}
);
},
[id, isFavorite, toggleFavorite]
);
const handleAddToCart = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (localQuantity > availableStock) {
setShowStockModal(true);
return;
}
setIsSyncing(true);
try {
await addToCartMutation.mutateAsync({
productId: id,
quantity: localQuantity,
});
toast.success(t("added_to_cart"), {
description: `${name} ${t("added_to_cart_description")}`,
});
} catch (error) {
console.error("Add to cart error:", error);
toast.error(t("add_to_cart_failed"));
} finally {
setIsSyncing(false);
}
},
[id, name, localQuantity, availableStock, addToCartMutation]
);
const handleQuantityChange = useCallback(
(e: MouseEvent<HTMLButtonElement>, delta: number) => {
e.preventDefault();
e.stopPropagation();
const newQuantity = localQuantity + delta;
if (newQuantity < 1) return;
if (newQuantity > availableStock) {
setShowStockModal(true);
return;
}
setLocalQuantity(newQuantity);
},
[localQuantity, availableStock]
);
const handleCardClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Prevent navigation if clicking on buttons or interactive elements
if (
target.closest("button") ||
target.closest('[data-carousel-control="true"]') ||
target.closest('[role="dialog"]')
) {
e.preventDefault();
e.stopPropagation();
return;
}
// Programmatic navigation
e.preventDefault();
router.push(`/product/${id}`);
}, [router, id]);
const handleNavClick = (e: MouseEvent, action: () => void) => {
e.preventDefault();
e.stopPropagation();
action();
};
return (
<>
<div
onClick={handleCardClick}
className="flex justify-center cursor-pointer"
>
<Card
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
style={{ height, maxWidth: width }}
>
<div className="relative w-full h-[260px] group">
<Carousel
opts={{ align: "start", loop: true, watchDrag: false }}
setApi={setApi}
className="w-full h-full"
>
<CarouselContent className="h-[260px] ml-0">
{images.map((image, idx) => (
<CarouselItem key={idx} className="h-[260px] pl-0">
<div className="h-full flex items-center justify-center">
<img
src={image}
alt={`${name} - ${idx + 1}`}
className="max-w-full max-h-full object-contain"
draggable="false"
/>
</div>
</CarouselItem>
))}
</CarouselContent>
{hasMultipleImages && (
<>
<CarouselPrevious
data-carousel-control="true"
className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollPrev())}
/>
<CarouselNext
data-carousel-control="true"
className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollNext())}
/>
</>
)}
</Carousel>
<button
onClick={handleFavorite}
disabled={isFavoriteToggling || isFavoriteLoading}
className="absolute top-3 cursor-pointer right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all disabled:opacity-50"
>
{isFavoriteLoading ? (
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
) : (
<Heart
className={`w-5 h-5 ${
isFavorite ? "text-[#005bff] fill-[#005bff]" : "text-gray-700"
}`}
/>
)}
</button>
{hasMultipleImages && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, idx) => (
<button
key={idx}
data-carousel-control="true"
onClick={(e) => handleNavClick(e, () => api?.scrollTo(idx))}
className={`h-1.5 rounded-full cursor-pointer transition-all ${
idx === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
}`}
/>
))}
</div>
)}
{labels.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
{labels.map((label, idx) => (
<Badge
key={idx}
className="text-white text-[10px] font-bold uppercase rounded-r-md"
style={{ backgroundColor: label.bg_color }}
>
{label.text}
</Badge>
))}
</div>
)}
{isOutOfStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<Badge variant="secondary" className="text-sm font-bold">
{t("outOfStock")}
</Badge>
</div>
)}
</div>
<CardContent className="p-0 space-y-1">
<p
className="text-sm mx-2 font-medium"
style={{ color: price_color }}
>
{struct_price_text}
</p>
<p className="text-black text-sm font-semibold leading-normal truncate mx-2">
{name}
</p>
</CardContent>
{button && !isOutOfStock && (
<div className="px-1">
{!isInCart ? (
<Button
onClick={handleAddToCart}
disabled={isSyncing}
className="w-full rounded-lg cursor-pointer gap-2 bg-[#005bff] hover:bg-[#0041c4]"
size="sm"
>
{isSyncing ? (
<>
{t("adding")}
</>
) : (
<>
<ShoppingCart className="h-4 w-4" />
{t("add_to_cart")}
</>
)}
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, -1)}
disabled={isSyncing || localQuantity <= 1}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Minus className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-semibold text-sm border rounded-lg h-9 flex items-center justify-center bg-white relative">
{localQuantity}
</div>
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, 1)}
disabled={isSyncing}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4 text-[#005bff]" />
</Button>
</div>
)}
</div>
)}
</Card>
</div>
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: name,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={(e) => {
e.stopPropagation();
setShowStockModal(false);
}}
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import ProductCard from "@/features/home/components/ProductCard";
import { useCollectionProducts } from "@/features/collections/hooks/useCollections";
import type { Collection } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
collection: Collection;
locale: string;
};
export default function CollectionSection({ collection, locale }: Props) {
const router = useRouter();
const {
data: productsData,
isLoading,
isError,
} = useCollectionProducts(collection.id);
const handleTitleClick = () => {
router.push(`/collections/${collection.slug}`);
};
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
</section>
);
}
if (isError) return null;
// Hide section if no products
if (!productsData?.data || productsData.data.length === 0) {
return null;
}
const displayProducts = productsData?.data.slice(0, 10) || [];
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div
className="flex items-center justify-between mb-4 cursor-pointer group"
onClick={handleTitleClick}
>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{collection.name}
</h2>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{displayProducts.map((product) => {
const allImages = product.media
?.map(
(m) =>
m.images_800x800 ||
m.images_720x720 ||
m.images_400x400 ||
m.thumbnail
)
.filter(Boolean) || ["/placeholder-product.jpg"];
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: "Price not available";
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={formattedPrice}
images={allImages}
labels={[]}
price_color="#0059ff"
height={360}
width={250}
/>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,150 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Collection, Product, PaginatedResponse } from "@/lib/types/api";
// Get ALL collections (fetch all pages)
export function useCollections(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["collections"],
queryFn: async () => {
const allCollections: Collection[] = [];
let currentPage = 1;
let hasMorePages = true;
while (hasMorePages) {
const response = await apiClient.get<PaginatedResponse<Collection>>(
"/collections",
{ params: { page: currentPage, perPage: 50 } }
);
const collections = response.data.data || [];
allCollections.push(...collections);
// Check if there are more pages
const pagination = response.data.pagination;
if (pagination && pagination.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
}
return allCollections;
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
});
}
// Get single collection by ID
export function useCollection(
id: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", id],
queryFn: async () => {
const response = await apiClient.get<Collection>(`/collections/${id}`);
return response.data;
},
enabled: options?.enabled !== false && !!id,
staleTime: 1000 * 60 * 15,
});
}
// Get ALL products for a collection (fetch all pages)
export function useCollectionProducts(
collectionId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "products"],
queryFn: async () => {
const allProducts: Product[] = [];
let currentPage = 1;
let hasMorePages = true;
while (hasMorePages) {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params: { page: currentPage, perPage: 50 } }
);
const products = response.data.data || [];
allProducts.push(...products);
// Check if there are more pages
const pagination = response.data.pagination;
if (pagination && pagination.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
}
return {
data: allProducts,
isEmpty: allProducts.length === 0,
};
},
enabled: options?.enabled !== false && !!collectionId,
});
}
// Check if collection has products (limit=1 for efficiency)
export function useCollectionHasProducts(
collectionId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "has-products"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params: { perPage: 20 } }
);
return {
hasProducts: response.data.data && response.data.data.length > 0,
};
},
enabled: options?.enabled !== false && !!collectionId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// Get collection products with infinite scroll (recommended for UI)
export function useCollectionProductsInfinite(
collectionId: number | string,
options?: { enabled?: boolean; perPage?: number }
) {
const perPage = options?.perPage || 6;
return useInfiniteQuery({
queryKey: ["collection", collectionId, "products-infinite", perPage],
queryFn: async ({ pageParam = 1 }) => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{
params: {
page: pageParam,
perPage,
},
}
);
return {
data: response.data.data || [],
pagination: response.data.pagination,
isEmpty: !response.data.data || response.data.data.length === 0,
};
},
getNextPageParam: (lastPage) => {
if (lastPage.pagination?.next_page_url) {
const currentPage = lastPage.pagination.page || 1;
return currentPage + 1;
}
return undefined;
},
enabled: options?.enabled !== false && !!collectionId,
initialPageParam: 1,
});
}

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
import type { Carousel, Banner, PaginatedResponse } from "@/lib/types/api"
// Get all carousels
export function useCarousels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["carousels"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Carousel>>("/media/carousels")
return response.data.data || response.data
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
})
}
// Get all banners
export function useBanners(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["banners"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Banner>>("/media/banners")
return response.data.data || response.data
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
})
}