first commit
This commit is contained in:
234
features/category/components/CategoryFilters.tsx
Normal file
234
features/category/components/CategoryFilters.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useCallback } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { FilterBrand, FilterCategory } from "@/lib/types/api";
|
||||
|
||||
interface FiltersData {
|
||||
categories: FilterCategory[];
|
||||
brands: FilterBrand[];
|
||||
}
|
||||
|
||||
interface CategoryFiltersProps {
|
||||
filtersData: FiltersData | undefined;
|
||||
selectedBrands: Set<number>;
|
||||
selectedFilterCategories: Set<number>;
|
||||
priceSort: "none" | "lowToHigh" | "highToLow";
|
||||
priceRange: [number, number];
|
||||
onBrandToggle: (brandId: number) => void;
|
||||
onCategoryToggle: (categoryId: number) => void;
|
||||
onPriceSortChange: (sortType: "none" | "lowToHigh" | "highToLow") => void;
|
||||
onPriceChange: (values: number[]) => void;
|
||||
onReset: () => void;
|
||||
translations: {
|
||||
category: string;
|
||||
brands: string;
|
||||
sort: string;
|
||||
default: string;
|
||||
price_low_to_high: string;
|
||||
price_high_to_low: string;
|
||||
price: string;
|
||||
price_from: string;
|
||||
price_to: string;
|
||||
reset: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CategoryFilters({
|
||||
filtersData,
|
||||
selectedBrands,
|
||||
selectedFilterCategories,
|
||||
priceSort,
|
||||
priceRange,
|
||||
onBrandToggle,
|
||||
onCategoryToggle,
|
||||
onPriceSortChange,
|
||||
onPriceChange,
|
||||
onReset,
|
||||
translations,
|
||||
}: CategoryFiltersProps) {
|
||||
return (
|
||||
<div className="space-y-6 mb-6">
|
||||
{filtersData?.categories && filtersData.categories.length > 0 && (
|
||||
<FilterSection title={translations.category}>
|
||||
{filtersData.categories.map((category) => (
|
||||
<CheckboxItem
|
||||
key={category.id}
|
||||
checked={selectedFilterCategories.has(category.id)}
|
||||
onCheckedChange={() => onCategoryToggle(category.id)}
|
||||
label={category.name}
|
||||
/>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{filtersData?.brands && filtersData.brands.length > 0 && (
|
||||
<FilterSection title={translations.brands}>
|
||||
{filtersData.brands.map((brand) => (
|
||||
<CheckboxItem
|
||||
key={brand.id}
|
||||
checked={selectedBrands.has(brand.id)}
|
||||
onCheckedChange={() => onBrandToggle(brand.id)}
|
||||
label={brand.name}
|
||||
/>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* <FilterSection title={translations.sort}>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "none"}
|
||||
onChange={() => onPriceSortChange("none")}
|
||||
label={translations.default}
|
||||
/>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "lowToHigh"}
|
||||
onChange={() => onPriceSortChange("lowToHigh")}
|
||||
label={translations.price_low_to_high}
|
||||
/>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "highToLow"}
|
||||
onChange={() => onPriceSortChange("highToLow")}
|
||||
label={translations.price_high_to_low}
|
||||
/>
|
||||
</FilterSection> */}
|
||||
|
||||
<PriceFilter
|
||||
title={translations.price}
|
||||
priceRange={priceRange}
|
||||
onPriceChange={onPriceChange}
|
||||
translations={{
|
||||
from: translations.price_from,
|
||||
to: translations.price_to,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="w-full rounded-lg cursor-pointer mb-6" onClick={onReset}>
|
||||
{translations.reset}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioItem({
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
name: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceFilter({
|
||||
title,
|
||||
priceRange,
|
||||
onPriceChange,
|
||||
translations,
|
||||
}: {
|
||||
title: string;
|
||||
priceRange: [number, number];
|
||||
onPriceChange: (values: number[]) => void;
|
||||
translations: { from: string; to: string };
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-from" className="text-xs mb-1">
|
||||
{translations.from}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-from"
|
||||
type="number"
|
||||
value={priceRange[0]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([parseInt(e.target.value) || 0, priceRange[1]])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-to" className="text-xs mb-1">
|
||||
{translations.to}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-to"
|
||||
type="number"
|
||||
value={priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([
|
||||
priceRange[0],
|
||||
parseInt(e.target.value) || 10000,
|
||||
])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={99999}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onValueChange={onPriceChange}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
features/category/components/CategoryFiltersSheet.tsx
Normal file
55
features/category/components/CategoryFiltersSheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface CategoryFiltersSheetProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filterLabel: string;
|
||||
closeLabel: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CategoryFiltersSheet({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
filterLabel,
|
||||
closeLabel,
|
||||
children,
|
||||
}: CategoryFiltersSheetProps) {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#005bff] hover:bg-[#0041c4] sm:hidden fixed bottom-20 right-4 rounded-lg cursor-pointer font-bold gap-2 z-10 shadow-lg"
|
||||
size="lg"
|
||||
>
|
||||
{filterLabel}
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[290px] p-0">
|
||||
<SheetHeader className="p-4 border-b">
|
||||
<SheetTitle>{filterLabel}</SheetTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute top-4 right-4 rounded-md cursor-pointer ring-offset-background transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</button>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
395
features/category/components/CategoryPageClient.tsx
Normal file
395
features/category/components/CategoryPageClient.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useCategories,
|
||||
useCategoryFilters,
|
||||
useFilteredCategoryProducts,
|
||||
} from "@/features/category/hooks/useCategories";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Category, Product } from "@/lib/types/api";
|
||||
import CategoryFilters from "./CategoryFilters";
|
||||
import CategoryProductsGrid from "./CategoryProductsGrid";
|
||||
import CategoryFiltersSheet from "./CategoryFiltersSheet";
|
||||
import ErrorPage from "@/components/ErrorPage";
|
||||
|
||||
interface CategoryPageClientProps {
|
||||
params: { locale: string; slug: string };
|
||||
}
|
||||
|
||||
export default function CategoryPageClient({
|
||||
params,
|
||||
}: CategoryPageClientProps) {
|
||||
const { slug } = params;
|
||||
const t = useTranslations();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: categoriesData,
|
||||
isLoading: categoriesLoading,
|
||||
isError: categoriesError
|
||||
} = useCategories();
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!categoriesData || !slug) return null;
|
||||
|
||||
const findBySlug = (categories: Category[]): Category | null => {
|
||||
for (const category of categories) {
|
||||
if (category.slug === slug) return category;
|
||||
if (category.children) {
|
||||
const found = findBySlug(category.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findBySlug(categoriesData);
|
||||
}, [categoriesData, slug]);
|
||||
|
||||
// State management
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [priceSort, setPriceSort] = useState<
|
||||
"none" | "lowToHigh" | "highToLow"
|
||||
>("none");
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
||||
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
|
||||
const [selectedFilterCategories, setSelectedFilterCategories] = useState<
|
||||
Set<number>
|
||||
>(new Set());
|
||||
|
||||
// Fetch filters
|
||||
const {
|
||||
data: filtersData,
|
||||
isLoading: filtersLoading,
|
||||
isError: filtersError
|
||||
} = useCategoryFilters(selectedCategory?.id, {
|
||||
enabled: !!selectedCategory,
|
||||
});
|
||||
|
||||
// Build filter params
|
||||
const filterParams = useMemo(() => {
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 6,
|
||||
};
|
||||
|
||||
if (selectedBrands.size > 0) {
|
||||
params.brands = Array.from(selectedBrands);
|
||||
}
|
||||
|
||||
if (selectedFilterCategories.size > 0) {
|
||||
params.categories = Array.from(selectedFilterCategories);
|
||||
}
|
||||
|
||||
params.min_price = priceRange[0];
|
||||
params.max_price = priceRange[1];
|
||||
|
||||
return params;
|
||||
}, [currentPage, selectedBrands, selectedFilterCategories, priceRange]);
|
||||
|
||||
// Fetch filtered products
|
||||
const {
|
||||
data: productsData,
|
||||
isFetching,
|
||||
isError: productsError
|
||||
} = useFilteredCategoryProducts(
|
||||
selectedCategory?.id?.toString() || "",
|
||||
filterParams,
|
||||
{ enabled: !!selectedCategory }
|
||||
);
|
||||
|
||||
// Reset on category change
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
setAllProducts([]);
|
||||
setCurrentPage(1);
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
// Update products list
|
||||
useEffect(() => {
|
||||
if (productsData?.data) {
|
||||
setAllProducts((prev) => {
|
||||
if (currentPage === 1) {
|
||||
return productsData.data;
|
||||
}
|
||||
|
||||
const existingIds = new Set(prev.map((p) => p.id));
|
||||
const newProducts = productsData.data.filter(
|
||||
(p: Product) => !existingIds.has(p.id)
|
||||
);
|
||||
|
||||
if (newProducts.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return [...prev, ...newProducts];
|
||||
});
|
||||
}
|
||||
}, [productsData?.data, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
if (!productsData?.pagination) return false;
|
||||
|
||||
if (productsData.pagination.next_page_url) return true;
|
||||
|
||||
if (
|
||||
productsData.pagination.current_page &&
|
||||
productsData.pagination.last_page
|
||||
) {
|
||||
return (
|
||||
productsData.pagination.current_page < productsData.pagination.last_page
|
||||
);
|
||||
}
|
||||
|
||||
if (productsData.pagination.hasMorePages !== undefined) {
|
||||
return productsData.pagination.hasMorePages;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [productsData?.pagination]);
|
||||
|
||||
const loadMoreData = useCallback(() => {
|
||||
if (!hasMore || isFetching) return;
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
const products = [...allProducts];
|
||||
if (priceSort === "lowToHigh") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0")
|
||||
);
|
||||
}
|
||||
if (priceSort === "highToLow") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0")
|
||||
);
|
||||
}
|
||||
return products;
|
||||
}, [allProducts, priceSort]);
|
||||
|
||||
// Filter handlers
|
||||
const handleBrandToggle = useCallback((brandId: number) => {
|
||||
setSelectedBrands((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.has(brandId) ? newSet.delete(brandId) : newSet.add(brandId);
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handleCategoryToggle = useCallback((categoryId: number) => {
|
||||
setSelectedFilterCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.has(categoryId)
|
||||
? newSet.delete(categoryId)
|
||||
: newSet.add(categoryId);
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceChange = useCallback((values: number[]) => {
|
||||
setPriceRange([values[0], values[1]]);
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceSortChange = useCallback(
|
||||
(sortType: "none" | "lowToHigh" | "highToLow") => {
|
||||
setPriceSort(sortType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const filterTranslations = useMemo(
|
||||
() => ({
|
||||
category: t("category"),
|
||||
brands: t("brands"),
|
||||
sort: t("sort"),
|
||||
default: t("default"),
|
||||
price_low_to_high: t("price_low_to_high"),
|
||||
price_high_to_low: t("price_high_to_low"),
|
||||
price: t("price"),
|
||||
price_from: t("price_from"),
|
||||
price_to: t("price_to"),
|
||||
reset: t("reset"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// ERROR STATE
|
||||
if (categoriesError || productsError || filtersError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
// LOADING STATE
|
||||
if (categoriesLoading) {
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
|
||||
{/* Title Skeleton */}
|
||||
<Skeleton className="h-16 w-full rounded-t-lg mb-0 bg-white" />
|
||||
|
||||
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg mt-0">
|
||||
{/* Desktop Filters Skeleton */}
|
||||
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 space-y-6">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Products Grid Skeleton */}
|
||||
<div className="flex-1">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="w-full aspect-square rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CATEGORY NOT FOUND
|
||||
if (!selectedCategory) {
|
||||
return <div className="text-center py-8">{t("category_not_found")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
|
||||
<h2 className="p-4 text-3xl font-bold pb-6 rounded-t-lg mb-0 bg-white">
|
||||
{selectedCategory.name}
|
||||
</h2>
|
||||
|
||||
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg">
|
||||
{/* Desktop Filters Sidebar */}
|
||||
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
|
||||
<ScrollArea className="h-auto">
|
||||
{filtersLoading ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<CategoryFilters
|
||||
filtersData={filtersData}
|
||||
selectedBrands={selectedBrands}
|
||||
selectedFilterCategories={selectedFilterCategories}
|
||||
priceSort={priceSort}
|
||||
priceRange={priceRange}
|
||||
onBrandToggle={handleBrandToggle}
|
||||
onCategoryToggle={handleCategoryToggle}
|
||||
onPriceSortChange={handlePriceSortChange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onReset={resetFilters}
|
||||
translations={filterTranslations}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="flex-1 bg-white rounded-lg mb-6">
|
||||
<CategoryProductsGrid
|
||||
products={sortedProducts}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMoreData}
|
||||
isFetching={isFetching}
|
||||
translations={{
|
||||
loading: t("common.loading"),
|
||||
no_results: t("no_results"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filters Sheet */}
|
||||
<CategoryFiltersSheet
|
||||
isOpen={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
filterLabel={t("filter")}
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
{filtersLoading ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<CategoryFilters
|
||||
filtersData={filtersData}
|
||||
selectedBrands={selectedBrands}
|
||||
selectedFilterCategories={selectedFilterCategories}
|
||||
priceSort={priceSort}
|
||||
priceRange={priceRange}
|
||||
onBrandToggle={handleBrandToggle}
|
||||
onCategoryToggle={handleCategoryToggle}
|
||||
onPriceSortChange={handlePriceSortChange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onReset={resetFilters}
|
||||
translations={filterTranslations}
|
||||
/>
|
||||
)}
|
||||
</CategoryFiltersSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
features/category/components/CategoryProductsGrid.tsx
Normal file
83
features/category/components/CategoryProductsGrid.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import ProductCard from "@/features/home/components/ProductCard";
|
||||
import type { Product } from "@/lib/types/api";
|
||||
|
||||
interface CategoryProductsGridProps {
|
||||
products: Product[];
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
isFetching?: boolean;
|
||||
translations: {
|
||||
loading: string;
|
||||
no_results: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CategoryProductsGrid({
|
||||
products,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
isFetching = false,
|
||||
translations,
|
||||
}: CategoryProductsGridProps) {
|
||||
if (products.length === 0 && !isFetching) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{translations.no_results}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
dataLength={products.length}
|
||||
next={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
scrollThreshold={0.8}
|
||||
style={{ overflow: "visible" }}
|
||||
loader={
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||
<span>{translations.loading}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
endMessage={
|
||||
products.length > 0 && !hasMore ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={
|
||||
product.price_amount ? parseFloat(product.price_amount) : null
|
||||
}
|
||||
struct_price_text={`${product.price_amount} TMT`}
|
||||
images={[product.media?.[0]?.images_400x400]}
|
||||
stock={product.stock}
|
||||
button={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isFetching && products.length === 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-gray-200 h-48 rounded-lg mb-2" />
|
||||
<div className="bg-gray-200 h-4 rounded w-3/4 mb-2" />
|
||||
<div className="bg-gray-200 h-4 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
);
|
||||
}
|
||||
17
features/category/components/CategorySkeleton.tsx
Normal file
17
features/category/components/CategorySkeleton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// import { Skeleton } from "@/components/ui/skeleton"
|
||||
// import { Card } from "@/components/ui/card"
|
||||
// import { CardContent } from "@/components/ui/card"
|
||||
|
||||
// export default function CategorySkeleton() {
|
||||
// return (
|
||||
// <Card className="overflow-hidden rounded-xl">
|
||||
// {/* Image */}
|
||||
// <Skeleton className="w-full h-36 bg-gray-200" />
|
||||
|
||||
// {/* Name */}
|
||||
// <CardContent className="py-2">
|
||||
// <Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// )
|
||||
// }
|
||||
Reference in New Issue
Block a user