product list

This commit is contained in:
hamid zarghami
2025-09-23 09:07:32 +03:30
parent 2a4608e258
commit f8ff05357d
10 changed files with 547 additions and 9 deletions
+1
View File
@@ -11,6 +11,7 @@
"@headlessui/react": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
+1
View File
@@ -13,6 +13,7 @@
"@headlessui/react": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
+89
View File
@@ -0,0 +1,89 @@
import { type FC } from 'react';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationEllipsis,
} from './ui/pagination';
import { type PagerType } from '../pages/products/types/Types';
interface PaginationUiProps {
pager: PagerType | null | undefined;
onPageChange: (page: number) => void;
}
const PaginationUi: FC<PaginationUiProps> = ({ pager, onPageChange }) => {
const handlePageChange = (page: number) => {
onPageChange(page);
};
const getPageNumbers = () => {
if (!pager) return [];
const pageNumbers: (number | 'ellipsis')[] = [];
const maxVisiblePages = 5;
if (pager.totalPages <= maxVisiblePages) {
for (let i = 1; i <= pager.totalPages; i++) {
pageNumbers.push(i);
}
} else {
if (pager.page > 3) {
pageNumbers.push(1);
if (pager.page > 4) {
pageNumbers.push('ellipsis');
}
}
const start = Math.max(2, pager.page - 1);
const end = Math.min(pager.totalPages - 1, pager.page + 1);
for (let i = start; i <= end; i++) {
pageNumbers.push(i);
}
if (pager.page < pager.totalPages - 2) {
if (pager.page < pager.totalPages - 3) {
pageNumbers.push('ellipsis');
}
pageNumbers.push(pager.totalPages);
}
}
return pageNumbers;
};
if (!pager || pager.totalPages <= 1) {
return null;
}
return (
<div className="flex ی justify-center mt-6">
<Pagination>
<PaginationContent>
{getPageNumbers().map((page, index) => (
<PaginationItem key={index}>
{page === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationLink
href="#"
isActive={page === pager.page}
onClick={(e) => {
e.preventDefault();
handlePageChange(page);
}}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
</PaginationContent>
</Pagination>
</div>
);
};
export default PaginationUi;
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+133 -1
View File
@@ -1,9 +1,141 @@
import { type FC } from 'react'
import { type FC, useState } from 'react'
import { useGetProducts } from './hooks/useProductData';
import { type ProductListType } from './types/Types';
import { ProductStatus } from './enum/ProductEnum';
import PageLoading from '../../components/PageLoading';
import Error from '../../components/Error';
import PaginationUi from '../../components/PaginationUi';
import StatusWithText from '../../components/StatusWithText';
import Td from '../../components/Td';
import TrashWithConfrim from '../../components/TrashWithConfrim';
import { Edit, Eye } from 'iconsax-react';
const List: FC = () => {
const [currentPage, setCurrentPage] = useState(1);
const { data: productsData, isLoading, error } = useGetProducts(currentPage);
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Error errorText="خطا در بارگذاری محصولات" />
</div>
);
}
const products = productsData?.results?.products || [];
const pager = productsData?.results?.pager;
const getStatusVariant = (status: ProductStatus) => {
switch (status) {
case ProductStatus.Approved: return 'success';
case ProductStatus.Rejected: return 'error';
case ProductStatus.Pending: return 'warning';
case ProductStatus.Draft: return 'warning';
default: return 'warning';
}
};
const getStatusText = (status: ProductStatus) => {
switch (status) {
case ProductStatus.Approved: return 'تایید شده';
case ProductStatus.Rejected: return 'رد شده';
case ProductStatus.Pending: return 'در انتظار';
case ProductStatus.Draft: return 'پیش‌نویس';
default: return status;
}
};
return (
<div>
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={'تصویر'} />
<Td text={'عنوان'} />
<Td text={'برند'} />
<Td text={'دسته‌بندی'} />
<Td text={'وضعیت'} />
<Td text={'قیمت'} />
<Td text={'موجودی'} />
<Td text={'عملیات'} />
</tr>
</thead>
<tbody>
{products.length === 0 ? (
<tr className='tr'>
<td colSpan={8} className="text-center py-8 text-gray-500">
هیچ محصولی یافت نشد
</td>
</tr>
) : (
products.map((product: ProductListType) => (
<tr key={product._id} className='tr'>
<Td text="">
<div className="w-12 h-12 rounded-lg overflow-hidden">
<img
src={product.imagesUrl?.cover || '/placeholder-image.png'}
alt={product.title_fa}
className="w-full h-full object-cover"
/>
</div>
</Td>
<Td text="">
<div>
<div className="font-medium text-gray-900">
{product.title_fa}
</div>
<div className="text-gray-500 text-xs">
{product.title_en}
</div>
</div>
</Td>
<Td text={product.brand_title_fa} />
<Td text={product.category_title_fa} />
<Td text="">
<StatusWithText
variant={getStatusVariant(product.status)}
text={getStatusText(product.status)}
/>
</Td>
<Td text={`${product.default_variant?.price?.selling_price?.toLocaleString('fa-IR') || 0} تومان`} />
<Td text={String(product.default_variant?.stock || 0)} />
<Td text="">
<div className="flex items-center gap-2">
<Eye color='#8C90A3' size={19} />
<Edit color='#8C90A3' size={16} />
<TrashWithConfrim
onDelete={() => {
// TODO: Implement delete product
console.log('Delete product:', product._id);
}}
/>
</div>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
<PaginationUi
pager={pager}
onPageChange={(page) => {
setCurrentPage(page);
}}
/>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export enum ProductMarketStatus {
Out_of_stock = "out_of_stock",
Marketable = "Marketable",
stop_production = "stopProduction",
}
export enum ProductStatus {
Draft = "Draft",
Pending = "Pending",
Approved = "Approved",
Rejected = "Rejected",
}
export enum ProductSource {
LOCAL = "local",
IMPORT = "import",
}
export enum ProductDiscountType {
Fixed = "fixed",
Percent = "percent",
}
export enum CreateProductStep {
Detail = 1,
Attribute,
Image,
}
+8 -4
View File
@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import * as api from "../service/ProductService";
import {
type CreateProductDetailRequestType,
@@ -6,23 +6,27 @@ import {
type SaveProductRequestType
} from "../types/Types";
// Hook برای مرحله اول: ایجاد جزئیات محصول
export const useCreateProductDetail = () => {
return useMutation({
mutationFn: (variables: CreateProductDetailRequestType) => api.createProductDetail(variables),
});
};
// Hook برای مرحله دوم: افزودن ویژگی‌ها و توضیحات
export const useCreateProductAttribute = () => {
return useMutation({
mutationFn: (variables: CreateProductAttributeRequestType) => api.createProductAttribute(variables),
});
};
// Hook برای مرحله سوم: ذخیره نهایی محصول
export const useSaveProduct = () => {
return useMutation({
mutationFn: (variables: SaveProductRequestType) => api.saveProduct(variables),
});
};
export const useGetProducts = (page: number = 1, limit: number = 10) => {
return useQuery({
queryKey: ['products', page, limit],
queryFn: () => api.getProducts(page, limit),
});
};
+9 -4
View File
@@ -5,23 +5,28 @@ import {
type CreateProductAttributeRequestType,
type CreateProductAttributeResponseType,
type SaveProductRequestType,
type SaveProductResponseType
type SaveProductResponseType,
type GetProductsResponseType
} from "../types/Types";
// مرحله اول: ایجاد جزئیات محصول
export const createProductDetail = async (params: CreateProductDetailRequestType): Promise<CreateProductDetailResponseType> => {
const { data } = await axios.post(`/admin/products/creation/detail`, params);
return data;
};
// مرحله دوم: افزودن ویژگی‌ها و توضیحات
export const createProductAttribute = async (params: CreateProductAttributeRequestType): Promise<CreateProductAttributeResponseType> => {
const { data } = await axios.post(`/admin/products/creation/attribute`, params);
return data;
};
// مرحله سوم: ذخیره نهایی محصول
export const saveProduct = async (params: SaveProductRequestType): Promise<SaveProductResponseType> => {
const { data } = await axios.post(`/admin/products/creation/save`, params);
return data;
};
export const getProducts = async (page: number = 1, limit: number = 10): Promise<GetProductsResponseType> => {
const { data } = await axios.get(`/admin/products/native`, {
params: { page, limit }
});
return data;
};
+93
View File
@@ -1,4 +1,5 @@
// تایپ‌های مربوط به ایجاد محصول سه مرحله‌ای
import { ProductStatus } from "../enum/ProductEnum";
// مرحله اول: جزئیات محصول
export type CreateProductDetailRequestType = {
@@ -104,3 +105,95 @@ export type ProductType = {
imagesList?: string[];
coverImage?: string;
};
// تایپ‌های مربوط به لیست محصولات
export type PagerType = {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: boolean | null;
nextPage: string | null;
};
export type ProductPriceType = {
order_limit: number;
retailPrice: number;
selling_price: number;
is_specialSale: boolean;
discount_percent: number;
specialSale_order_limit: number | null;
specialSale_quantity: number | null;
specialSale_endDate: string | null;
};
export type ProductVariantType = {
_id: string;
market_status: string;
price: ProductPriceType;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: {
_id: string;
};
warranty: number;
size?: number;
meterage?: number;
};
export type ProductImagesType = {
cover: string;
list: string[];
};
export type ProductListType = {
_id: number;
title_fa: string;
title_en: string;
seoTitle: string | null;
seoDescription: string | null;
description: string;
metaDescription: string;
shopName: string;
category_title_fa: string;
category_id: string;
brand_title_fa: string;
status: ProductStatus;
adminComments: string | null;
step: number;
isFake: boolean;
imagesUrl: ProductImagesType;
variantCount: number;
default_variant: ProductVariantType;
popular: boolean;
incredible: boolean;
voice: string | null;
};
export type BrandListType = {
_id: string;
status: string;
title_en: string;
title_fa: string;
images: string[];
logoUrl: string;
};
export type CategoryListType = {
_id: string;
title_fa: string;
};
export type GetProductsResponseType = {
status: number;
success: boolean;
results: {
pager: PagerType;
products: ProductListType[];
brands: BrandListType[];
categories: CategoryListType[];
};
};