product list
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user