create product
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import axios from "../../../config/axios";
|
||||
import {
|
||||
type ApiResponse,
|
||||
type CheckHasAccountType,
|
||||
type LoginWithPasswordType,
|
||||
type RefreshTokenResponse,
|
||||
type RefreshTokenType,
|
||||
type RegisterType,
|
||||
} from "../types/AuthTypes";
|
||||
@@ -21,7 +23,10 @@ export const register = async (params: RegisterType) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const refreshToken = async (params: RefreshTokenType) => {
|
||||
const { data } = await axios.post(`/auth/refresh`, params);
|
||||
export const refreshToken = async (
|
||||
params: RefreshTokenType
|
||||
): Promise<ApiResponse<RefreshTokenResponse>> => {
|
||||
const { data } = await axios.post(`/user/token`, params);
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,5 +18,24 @@ export type RegisterType = {
|
||||
};
|
||||
|
||||
export type RefreshTokenType = {
|
||||
refreshToken: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type TokenInfo = {
|
||||
token: string;
|
||||
expires: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
data: T;
|
||||
};
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
tokenType: string;
|
||||
accessToken: TokenInfo;
|
||||
refreshToken: TokenInfo;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/BrandService";
|
||||
|
||||
// Hook برای دریافت لیست برندها
|
||||
export const useGetBrands = () => {
|
||||
return useQuery({
|
||||
queryKey: ["brands"],
|
||||
queryFn: api.getBrands,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import axios from "../../../config/axios";
|
||||
import { type BrandsResponseType } from "../types/Types";
|
||||
|
||||
// دریافت لیست برندها
|
||||
export const getBrands = async (): Promise<BrandsResponseType> => {
|
||||
const { data } = await axios.get(`/brand`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// تایپهای مربوط به برند
|
||||
|
||||
export type BrandType = {
|
||||
_id: string;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
category: string | null;
|
||||
status: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
images: string[];
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type PagerType = {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
prevPage: boolean | string;
|
||||
nextPage: string;
|
||||
};
|
||||
|
||||
export type BrandsResponseType = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
brands: BrandType[];
|
||||
pager: PagerType;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import { useCreateProductDetail, useCreateProductAttribute, useSaveProduct } from './hooks/useProductData';
|
||||
import { useGetBrands } from '../brand/hooks/useBrandData';
|
||||
import { useGetCategories } from '../category/hooks/useCategoryData';
|
||||
import { StepIndicator, Step1Form, Step2Form, Step3Form } from './components';
|
||||
import { type CreateProductDetailRequestType, type CreateProductAttributeRequestType, type SaveProductRequestType } from './types/Types';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Pages } from '@/config/Pages';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { extractErrorMessage } from '@/helpers/utils';
|
||||
|
||||
const CreateProduct: FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [productData, setProductData] = useState<Partial<CreateProductDetailRequestType & CreateProductAttributeRequestType & SaveProductRequestType>>({});
|
||||
const navigate = useNavigate();
|
||||
// API hooks
|
||||
const createProductDetailMutation = useCreateProductDetail();
|
||||
const createProductAttributeMutation = useCreateProductAttribute();
|
||||
const saveProductMutation = useSaveProduct();
|
||||
const { data: brandsData } = useGetBrands();
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories();
|
||||
|
||||
const handleStep1Submit = (data: CreateProductDetailRequestType) => {
|
||||
createProductDetailMutation.mutate(data, {
|
||||
onSuccess: (response) => {
|
||||
setProductData(prev => ({ ...prev, ...data, productId: response?.results?.draftProduct?.id }));
|
||||
setCurrentStep(2);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStep2Submit = (data: Omit<CreateProductAttributeRequestType, 'productId'>) => {
|
||||
const fullData: CreateProductAttributeRequestType = {
|
||||
...data,
|
||||
productId: productData.productId!
|
||||
};
|
||||
|
||||
createProductAttributeMutation.mutate(fullData, {
|
||||
onSuccess: () => {
|
||||
setProductData(prev => ({ ...prev, ...fullData }));
|
||||
setCurrentStep(3);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStep3Submit = (data: Omit<SaveProductRequestType, 'productId'>) => {
|
||||
const fullData: SaveProductRequestType = {
|
||||
...data,
|
||||
productId: productData.productId!
|
||||
};
|
||||
|
||||
saveProductMutation.mutate(fullData, {
|
||||
onSuccess: () => {
|
||||
toast.success('محصول با موفقیت ذخیره شد');
|
||||
navigate(Pages.products.list);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractErrorMessage(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviousStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep1 = () => {
|
||||
// اطمینان از اینکه دادهها درست منتقل میشوند
|
||||
const categories = categoriesData?.results?.data || [];
|
||||
const brands = brandsData?.results?.brands || [];
|
||||
|
||||
// اگر categories در حال loading است، منتظر بمان
|
||||
if (categoriesLoading) {
|
||||
return <div>در حال بارگذاری دستهبندیها...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Step1Form
|
||||
onSubmit={handleStep1Submit}
|
||||
brands={brands}
|
||||
categories={categories}
|
||||
loading={createProductDetailMutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep2 = () => (
|
||||
<Step2Form
|
||||
onSubmit={handleStep2Submit}
|
||||
loading={createProductAttributeMutation.isPending}
|
||||
onPrevious={handlePreviousStep}
|
||||
categoryId={productData.category}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderStep3 = () => (
|
||||
<Step3Form
|
||||
onSubmit={handleStep3Submit}
|
||||
loading={saveProductMutation.isPending}
|
||||
onPrevious={handlePreviousStep}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">ایجاد محصول جدید</h1>
|
||||
<div className="flex items-center justify-center space-x-8">
|
||||
<StepIndicator step={1} currentStep={currentStep} title="جزئیات محصول" />
|
||||
<StepIndicator step={2} currentStep={currentStep} title="ویژگیها و توضیحات" />
|
||||
<StepIndicator step={3} currentStep={currentStep} title="تصاویر و ذخیره" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
{currentStep === 3 && renderStep3()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProduct;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
const List: FC = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default List
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type FC, useState, useMemo } from 'react';
|
||||
import Button from '../../../components/Button';
|
||||
import Input from '../../../components/Input';
|
||||
import Select from '../../../components/Select';
|
||||
import Switch from '../../../components/Switch';
|
||||
import RadioGroup from '../../../components/RadioGroup';
|
||||
import { type CreateProductDetailRequestType } from '../types/Types';
|
||||
import { type CategoryTreeNode } from '../../category/types/Types';
|
||||
|
||||
interface Step1FormProps {
|
||||
onSubmit: (data: CreateProductDetailRequestType) => void;
|
||||
brands: { _id: string; title_fa: string }[];
|
||||
categories: CategoryTreeNode[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// تابع برای پیدا کردن همه دستهبندیهای leaf از تمام سطحها
|
||||
const findAllLeafCategories = (categories: CategoryTreeNode[]): CategoryTreeNode[] => {
|
||||
const leafCategories: CategoryTreeNode[] = [];
|
||||
|
||||
const traverse = (nodes: CategoryTreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.leaf) {
|
||||
leafCategories.push(node);
|
||||
}
|
||||
// مهم: همیشه children ها را هم بررسی کن
|
||||
if (node.children && node.children.length > 0) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(categories);
|
||||
return leafCategories;
|
||||
};
|
||||
|
||||
|
||||
const Step1Form: FC<Step1FormProps> = ({ onSubmit, brands, categories, loading }) => {
|
||||
const [formData, setFormData] = useState<CreateProductDetailRequestType>({
|
||||
category: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
title_fa: '',
|
||||
title_en: '',
|
||||
seoTitle: '',
|
||||
source: 'local',
|
||||
isFake: false
|
||||
});
|
||||
|
||||
// پیدا کردن همه دستهبندیهای leaf
|
||||
const leafCategories = useMemo(() => {
|
||||
if (!categories || categories.length === 0) {
|
||||
// اگر دادهای وجود ندارد، لیست خالی برمیگردانیم
|
||||
return [];
|
||||
}
|
||||
|
||||
return findAllLeafCategories(categories);
|
||||
}, [categories]);
|
||||
|
||||
// تبدیل به فرمت مورد نیاز Combobox
|
||||
const categoryItems = useMemo(() =>
|
||||
leafCategories.map(cat => ({ value: cat._id, label: cat.title_fa })),
|
||||
[leafCategories]
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Select
|
||||
label="دستهبندی"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
items={categoryItems}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="برند"
|
||||
value={formData.brand}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, brand: e.target.value }))}
|
||||
items={brands.map(brand => ({ value: brand._id, label: brand.title_fa }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="مدل"
|
||||
value={formData.model}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, model: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="عنوان فارسی"
|
||||
value={formData.title_fa}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title_fa: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="عنوان انگلیسی"
|
||||
value={formData.title_en}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title_en: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="عنوان SEO"
|
||||
value={formData.seoTitle}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seoTitle: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
|
||||
<RadioGroup
|
||||
selected={formData.source}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, source: value as string }))}
|
||||
items={[
|
||||
{ value: 'local', label: 'محلی' },
|
||||
{ value: 'imported', label: 'وارداتی' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="محصول تقلبی"
|
||||
active={formData.isFake}
|
||||
onChange={(active) => setFormData(prev => ({ ...prev, isFake: active }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
مرحله بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step1Form;
|
||||
@@ -0,0 +1,304 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import Button from '../../../components/Button';
|
||||
import Input from '../../../components/Input';
|
||||
import Textarea from '../../../components/Textarea';
|
||||
import { useGetCategoryAttributes } from '../../category/hooks/useCategoryData';
|
||||
import { type CreateProductAttributeRequestType } from '../types/Types';
|
||||
import { type CategoryAttributeType } from '../../category/types/Types';
|
||||
import { CloseCircle } from 'iconsax-react';
|
||||
|
||||
interface AttributeSelectorProps {
|
||||
attribute: CategoryAttributeType;
|
||||
selectedValues: number[];
|
||||
onChange: (attributeId: number, values: number[]) => void;
|
||||
}
|
||||
|
||||
const AttributeSelector: FC<AttributeSelectorProps> = ({ attribute, selectedValues, onChange }) => {
|
||||
const handleValueChange = (valueId: number, checked: boolean) => {
|
||||
if (attribute.multiple) {
|
||||
// چند انتخابی - checkbox
|
||||
const newValues = checked
|
||||
? [...selectedValues, valueId]
|
||||
: selectedValues.filter(id => id !== valueId);
|
||||
onChange(attribute._id, newValues);
|
||||
} else {
|
||||
// تک انتخابی - radio
|
||||
onChange(attribute._id, checked ? [valueId] : []);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-2">
|
||||
{attribute.title}
|
||||
{attribute.required && <span className="text-red-500 mr-1">*</span>}
|
||||
</h3>
|
||||
|
||||
{attribute.hint && (
|
||||
<p className="text-xs text-gray-500 mb-3">{attribute.hint}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{attribute.values.map((value) => {
|
||||
const isChecked = selectedValues.includes(value._id);
|
||||
|
||||
return (
|
||||
<label key={value._id} className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type={attribute.multiple ? "checkbox" : "radio"}
|
||||
name={`attribute-${attribute._id}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleValueChange(value._id, e.target.checked)}
|
||||
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${attribute.multiple ? 'rounded' : 'rounded-full'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{value.text}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Step2FormProps {
|
||||
onSubmit: (data: Omit<CreateProductAttributeRequestType, 'productId'>) => void;
|
||||
loading: boolean;
|
||||
onPrevious: () => void;
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
// کامپوننت مدیریت تگها
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const TagInput: FC<TagInputProps> = ({ tags, onChange, placeholder }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
const newTag = inputValue.trim();
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
onChange([...tags, newTag]);
|
||||
setInputValue('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 border border-gray-300 rounded-md focus-within:border-blue-500">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="hover:bg-blue-200 rounded-full p-0.5"
|
||||
>
|
||||
<CloseCircle size={14} color='red' />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={tags.length === 0 ? placeholder : ''}
|
||||
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">برای افزودن تگ، تایپ کنید و Enter یا کاما بزنید</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// کامپوننت مدیریت لیست مزایا/معایب
|
||||
interface ListInputProps {
|
||||
items: string[];
|
||||
onChange: (items: string[]) => void;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ListInput: FC<ListInputProps> = ({ items, onChange, placeholder, label }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const addItem = () => {
|
||||
const newItem = inputValue.trim();
|
||||
if (newItem && !items.includes(newItem)) {
|
||||
onChange([...items, newItem]);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = (itemToRemove: string) => {
|
||||
onChange(items.filter(item => item !== itemToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-4 w-fit"
|
||||
>
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-2 flex flex-wrap gap-3 max-h-40 overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex min-w-[30%] flex-1 h-10 items-center justify-between p-2 bg-gray-50 rounded-md">
|
||||
<span className="text-sm text-gray-700">{item}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item)}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<CloseCircle size={16} color='red' />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className='min-w-[30%] flex-1 px-2'></div>
|
||||
<div className='min-w-[30%] flex-1 px-2'></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Step2Form: FC<Step2FormProps> = ({ onSubmit, loading, onPrevious, categoryId }) => {
|
||||
const [formData, setFormData] = useState<Omit<CreateProductAttributeRequestType, 'productId'>>({
|
||||
attributes: [],
|
||||
description: '',
|
||||
metaDescription: '',
|
||||
tags: [],
|
||||
advantages: [],
|
||||
disAdvantages: []
|
||||
});
|
||||
|
||||
const { data: categoryAttributesData } = useGetCategoryAttributes(categoryId || '');
|
||||
|
||||
const handleAttributeChange = (attributeId: number, values: number[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
attributes: prev.attributes.filter(attr => attr.id !== attributeId).concat({
|
||||
id: attributeId,
|
||||
values
|
||||
})
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* ویژگیهای محصول */}
|
||||
{categoryAttributesData?.results?.data?.category?.attributes && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">ویژگیهای محصول</h2>
|
||||
{categoryAttributesData.results.data.category.attributes.map((attribute) => {
|
||||
const selectedAttribute = formData.attributes.find(attr => attr.id === attribute._id);
|
||||
const selectedValues = selectedAttribute?.values || [];
|
||||
|
||||
return (
|
||||
<AttributeSelector
|
||||
key={attribute._id}
|
||||
attribute={attribute}
|
||||
selectedValues={selectedValues}
|
||||
onChange={handleAttributeChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="توضیحات"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="توضیحات متا"
|
||||
value={formData.metaDescription}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, metaDescription: e.target.value }))}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تگها</label>
|
||||
<TagInput
|
||||
tags={formData.tags}
|
||||
onChange={(tags) => setFormData(prev => ({ ...prev, tags }))}
|
||||
placeholder="تگ محصول را وارد کنید..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ListInput
|
||||
label="مزایا"
|
||||
items={formData.advantages}
|
||||
onChange={(advantages) => setFormData(prev => ({ ...prev, advantages }))}
|
||||
placeholder="مزیتی را وارد کنید..."
|
||||
/>
|
||||
|
||||
<ListInput
|
||||
label="معایب"
|
||||
items={formData.disAdvantages}
|
||||
onChange={(disAdvantages) => setFormData(prev => ({ ...prev, disAdvantages }))}
|
||||
placeholder="عیبی را وارد کنید..."
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
>
|
||||
مرحله قبلی
|
||||
</Button>
|
||||
<Button type="submit" isLoading={loading}>
|
||||
مرحله بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2Form;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import Button from '../../../components/Button';
|
||||
import UploadBoxDraggble from '../../../components/UploadBoxDraggble';
|
||||
import UploadBox from '../../../components/UploadBox';
|
||||
import { useUploadMultiple } from '../../uploader/hooks/useUploaderData';
|
||||
import { type SaveProductRequestType } from '../types/Types';
|
||||
|
||||
interface Step3FormProps {
|
||||
onSubmit: (data: Omit<SaveProductRequestType, 'productId'>) => void;
|
||||
loading: boolean;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
const Step3Form: FC<Step3FormProps> = ({ onSubmit, loading, onPrevious }) => {
|
||||
|
||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [coverImage, setCoverImage] = useState<string>('');
|
||||
const [selectedCoverFile, setSelectedCoverFile] = useState<File | null>(null);
|
||||
|
||||
const uploadMultipleMutation = useUploadMultiple();
|
||||
|
||||
const handleFileSelect = (files: File[]) => {
|
||||
setSelectedFiles(files);
|
||||
};
|
||||
|
||||
const handleCoverSelect = (url: string) => {
|
||||
setCoverImage(url);
|
||||
};
|
||||
|
||||
const handleCoverFileSelect = (files: File[]) => {
|
||||
setSelectedCoverFile(files.length > 0 ? files[0] : null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// اگر هیچ فایلی انتخاب نشده، مستقیم save کن
|
||||
if (selectedFiles.length === 0 && !selectedCoverFile) {
|
||||
const finalData = {
|
||||
imagesList: uploadedImages,
|
||||
coverImage: coverImage || uploadedImages[0] || ''
|
||||
};
|
||||
onSubmit(finalData);
|
||||
return;
|
||||
}
|
||||
|
||||
// آپلود فایلهای محصول
|
||||
if (selectedFiles.length > 0) {
|
||||
uploadMultipleMutation.mutate(selectedFiles, {
|
||||
onSuccess: (response) => {
|
||||
const newUrls = response?.results?.urls?.map(file => file.url) || [];
|
||||
const allImages = [...uploadedImages, ...newUrls];
|
||||
setUploadedImages(allImages);
|
||||
|
||||
// اگر فایل کاور هم انتخاب شده، آن را هم آپلود کن
|
||||
if (selectedCoverFile) {
|
||||
uploadMultipleMutation.mutate([selectedCoverFile], {
|
||||
onSuccess: (coverResponse) => {
|
||||
const coverUrl = coverResponse?.results?.urls?.[0]?.url || '';
|
||||
const finalData = {
|
||||
imagesList: allImages,
|
||||
coverImage: coverUrl || coverImage || allImages[0] || ''
|
||||
};
|
||||
onSubmit(finalData);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Cover upload failed:', error);
|
||||
// حتی اگر کاور آپلود نشد، با تصاویر محصول save کن
|
||||
const finalData = {
|
||||
imagesList: allImages,
|
||||
coverImage: coverImage || allImages[0] || ''
|
||||
};
|
||||
onSubmit(finalData);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// فقط تصاویر محصول آپلود شده، save کن
|
||||
const finalData = {
|
||||
imagesList: allImages,
|
||||
coverImage: coverImage || allImages[0] || ''
|
||||
};
|
||||
onSubmit(finalData);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Product images upload failed:', error);
|
||||
// میتوانیم اینجا پیام خطا نمایش دهیم
|
||||
}
|
||||
});
|
||||
} else if (selectedCoverFile) {
|
||||
// فقط فایل کاور انتخاب شده
|
||||
uploadMultipleMutation.mutate([selectedCoverFile], {
|
||||
onSuccess: (response) => {
|
||||
const coverUrl = response?.results?.urls?.[0]?.url || '';
|
||||
const finalData = {
|
||||
imagesList: uploadedImages,
|
||||
coverImage: coverUrl || coverImage || uploadedImages[0] || ''
|
||||
};
|
||||
onSubmit(finalData);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Cover upload failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* اپلود تصویر کاور */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تصویر کاور محصول
|
||||
</label>
|
||||
<UploadBox
|
||||
label="تصویر کاور را انتخاب کنید"
|
||||
onChange={handleCoverFileSelect}
|
||||
/>
|
||||
{coverImage && (
|
||||
<div className="mt-2 text-sm text-green-600">
|
||||
تصویر کاور انتخاب شده است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* اپلود تصاویر محصول */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تصاویر محصول
|
||||
</label>
|
||||
<UploadBoxDraggble
|
||||
label="تصاویر محصول را انتخاب یا اینجا رها کنید"
|
||||
onChange={handleFileSelect}
|
||||
isMultiple={true}
|
||||
preview={uploadedImages}
|
||||
onChangePreview={setUploadedImages}
|
||||
getCover={handleCoverSelect}
|
||||
coverUrl={coverImage}
|
||||
/>
|
||||
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
برای انتخاب تصویر کاور روی یکی از تصاویر کلیک کنید (اختیاری).
|
||||
{coverImage && (
|
||||
<span className="text-green-600 font-medium">
|
||||
{' '}تصویر کاور انتخاب شده است.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
>
|
||||
مرحله قبلی
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={loading || uploadMultipleMutation.isPending}
|
||||
disabled={selectedFiles.length === 0 && uploadedImages.length === 0 && !selectedCoverFile && !coverImage}
|
||||
>
|
||||
ایجاد محصول
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3Form;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type FC } from 'react';
|
||||
|
||||
interface StepIndicatorProps {
|
||||
step: number;
|
||||
currentStep: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const StepIndicator: FC<StepIndicatorProps> = ({ step, currentStep, title }) => {
|
||||
const isCompleted = step < currentStep;
|
||||
const isActive = step === currentStep;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* دایره اصلی */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${isCompleted
|
||||
? 'bg-gray-900 border-gray-900'
|
||||
: isActive
|
||||
? 'bg-gray-900 border-gray-900'
|
||||
: 'bg-white border-gray-300'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<span className="text-white text-xs">✓</span>
|
||||
) : (
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-white' : 'text-gray-400'}`}>
|
||||
{step}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* متن عنوان */}
|
||||
<span className={`text-sm font-medium ${isCompleted || isActive
|
||||
? 'text-gray-900'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepIndicator;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as StepIndicator } from "./StepIndicator";
|
||||
export { default as Step1Form } from "./Step1Form";
|
||||
export { default as Step2Form } from "./Step2Form";
|
||||
export { default as Step3Form } from "./Step3Form";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as api from "../service/ProductService";
|
||||
import {
|
||||
type CreateProductDetailRequestType,
|
||||
type CreateProductAttributeRequestType,
|
||||
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),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from "../../../config/axios";
|
||||
import {
|
||||
type CreateProductDetailRequestType,
|
||||
type CreateProductDetailResponseType,
|
||||
type CreateProductAttributeRequestType,
|
||||
type CreateProductAttributeResponseType,
|
||||
type SaveProductRequestType,
|
||||
type SaveProductResponseType
|
||||
} 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;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
// تایپهای مربوط به ایجاد محصول سه مرحلهای
|
||||
|
||||
// مرحله اول: جزئیات محصول
|
||||
export type CreateProductDetailRequestType = {
|
||||
category: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
seoTitle: string;
|
||||
source: string;
|
||||
isFake: boolean;
|
||||
};
|
||||
|
||||
export type DraftProductType = {
|
||||
id: number;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
model: string;
|
||||
nextStep: string;
|
||||
};
|
||||
|
||||
export type CreateProductDetailResponseType = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
message: string;
|
||||
draftProduct: DraftProductType;
|
||||
};
|
||||
};
|
||||
|
||||
// مرحله دوم: ویژگیها و توضیحات
|
||||
export type ProductAttributeType = {
|
||||
id: number;
|
||||
values: number[];
|
||||
};
|
||||
|
||||
export type CreateProductAttributeRequestType = {
|
||||
productId: number;
|
||||
attributes: ProductAttributeType[];
|
||||
description: string;
|
||||
metaDescription: string;
|
||||
tags: string[];
|
||||
advantages: string[];
|
||||
disAdvantages: string[];
|
||||
};
|
||||
|
||||
export type CreateProductAttributeResponseType = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// مرحله سوم: ذخیره نهایی
|
||||
export type SaveProductRequestType = {
|
||||
productId: number;
|
||||
imagesList: string[];
|
||||
coverImage: string;
|
||||
};
|
||||
|
||||
export type SaveProductResponseType = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
success: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// تایپ برند
|
||||
export type BrandType = {
|
||||
_id: string;
|
||||
name: string;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
};
|
||||
|
||||
export type BrandsResponseType = {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: {
|
||||
data: BrandType[];
|
||||
};
|
||||
};
|
||||
|
||||
// تایپ عمومی محصول
|
||||
export type ProductType = {
|
||||
productId: number;
|
||||
category: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
title_fa: string;
|
||||
title_en: string;
|
||||
seoTitle: string;
|
||||
source: string;
|
||||
isFake: boolean;
|
||||
attributes?: ProductAttributeType[];
|
||||
description?: string;
|
||||
metaDescription?: string;
|
||||
tags?: string[];
|
||||
advantages?: string[];
|
||||
disAdvantages?: string[];
|
||||
imagesList?: string[];
|
||||
coverImage?: string;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/UploaderService";
|
||||
|
||||
export const useUploadSingle = () => {
|
||||
return useMutation({
|
||||
mutationFn: (params: File) => api.uploadSingle(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadMultiple = () => {
|
||||
return useMutation({
|
||||
mutationFn: (params: File[]) => api.uploadMultiple(params),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import axios from "../../../config/axios";
|
||||
import type { UploadMultipleResponse } from "../types/Types";
|
||||
|
||||
export const uploadSingle = async (params: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", params);
|
||||
const { data } = await axios.post(`/admin/medias/upload/single`, formData);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const uploadMultiple = async (
|
||||
params: File[]
|
||||
): Promise<UploadMultipleResponse> => {
|
||||
const formData = new FormData();
|
||||
params.forEach((file) => {
|
||||
formData.append("file", file);
|
||||
});
|
||||
const { data } = await axios.post<UploadMultipleResponse>(
|
||||
`/admin/medias/upload/multiple`,
|
||||
formData
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface UploadedFile {
|
||||
originalname: string;
|
||||
size: number;
|
||||
url: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UploadMultipleResult {
|
||||
message: string;
|
||||
urls: UploadedFile[];
|
||||
}
|
||||
|
||||
export interface UploadMultipleResponse {
|
||||
status: number;
|
||||
success: boolean;
|
||||
results: UploadMultipleResult;
|
||||
}
|
||||
Reference in New Issue
Block a user