fix(product-options): update API routes and enhance product option models

This commit is contained in:
hosseintaromi 2025-07-28 09:11:08 +03:30
parent 58ecd2e94a
commit b3c3a8afd0
9 changed files with 5102 additions and 5401 deletions

View File

@ -1,4 +1,5 @@
import { clsx } from 'clsx';
import { MouseEvent } from 'react';
interface ButtonProps {
children: any;
@ -6,7 +7,7 @@ interface ButtonProps {
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
onClick?: (e?: MouseEvent<HTMLButtonElement>) => void;
type?: 'button' | 'submit' | 'reset';
className?: string;
}

View File

@ -120,7 +120,6 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
setFiles(prev => [...prev, newFile]);
try {
// شبیه‌سازی پروگرس
const progressInterval = setInterval(() => {
setFiles(prev => prev.map(f =>
f.id === fileId && f.progress < 90
@ -145,13 +144,13 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
: f
));
}
}, [onUpload, files.length, maxFiles, maxFileSize, acceptedTypes]);
}, [onUpload, maxFiles, maxFileSize, acceptedTypes]);
const handleFileSelect = (selectedFiles: FileList) => {
const handleFileSelect = useCallback((selectedFiles: FileList) => {
Array.from(selectedFiles).forEach(file => {
handleFileUpload(file);
});
};
}, [handleFileUpload]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -160,7 +159,7 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
const droppedFiles = e.dataTransfer.files;
handleFileSelect(droppedFiles);
}, [disabled, handleFileUpload]);
}, [disabled, handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -307,7 +306,7 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
e?.stopPropagation();
handleRemove(file.id);
}}
className="p-1 h-8 w-8"

View File

@ -39,8 +39,8 @@ export const API_ROUTES = {
// Product Options APIs
GET_PRODUCT_OPTIONS: "api/v1/product-options",
GET_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
CREATE_PRODUCT_OPTION: "api/v1/products/options",
UPDATE_PRODUCT_OPTION: (id: string) => `api/v1/products/options/${id}`,
CREATE_PRODUCT_OPTION: "api/v1/product-options",
UPDATE_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
DELETE_PRODUCT_OPTION: (id: string) => `api/v1/product-options/${id}`,
// Categories APIs

View File

@ -1,24 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import {
httpPostRequest,
httpDeleteRequest,
APIUrlGenerator,
} from "@/utils/baseHttpService";
import { toast } from "react-hot-toast";
import { APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import toast from "react-hot-toast";
interface UploadFileResponse {
id: string;
name: string;
size: number;
type: string;
url: string;
serve_key: string;
created_at: string;
}
import { httpPostRequest, httpDeleteRequest } from "@/utils/baseHttpService";
interface UploadResponse {
file: UploadFileResponse;
file: {
id: number;
url: string;
name: string;
original_name: string;
serve_key: string;
size: number;
mime_type: string;
created_at: string;
updated_at: string;
};
}
export const useFileUpload = () => {
@ -26,12 +23,18 @@ export const useFileUpload = () => {
mutationFn: async (file: File): Promise<{ id: string; url: string }> => {
const formData = new FormData();
formData.append("file", file);
formData.append("name", "uploaded-file");
console.log("Uploading file:", file.name);
const response = await httpPostRequest<UploadResponse>(
APIUrlGenerator(API_ROUTES.UPLOAD_FILE),
formData
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
console.log("Upload response:", response);
@ -41,7 +44,7 @@ export const useFileUpload = () => {
}
return {
id: response.data.file.id,
id: response.data.file.id.toString(),
url: response.data.file.url,
};
},

View File

@ -1,14 +1,31 @@
export interface Maintenance {
content: string;
description: string;
image: string;
title: string;
}
export interface Option {
description: string;
meta_title: string;
title: string;
}
export interface ProductOption {
id: number;
name: string;
values: string[];
title: string;
description: string;
maintenance: Maintenance;
options: Option[];
created_at: string;
updated_at: string;
}
export interface ProductOptionFormData {
name: string;
values: string[];
title: string;
description: string;
maintenance: Maintenance;
options: Option[];
}
export interface ProductOptionFilters {
@ -18,14 +35,18 @@ export interface ProductOptionFilters {
}
export interface CreateProductOptionRequest {
name: string;
values: string[];
title: string;
description: string;
maintenance: Maintenance;
options: Option[];
}
export interface UpdateProductOptionRequest {
id: number;
name: string;
values: string[];
title: string;
description: string;
maintenance: Maintenance;
options: Option[];
}
export interface ProductOptionsResponse {

View File

@ -1,19 +1,33 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useForm, useFieldArray } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { useProductOption, useCreateProductOption, useUpdateProductOption } from '../core/_hooks';
import { ProductOptionFormData } from '../core/_models';
import { ProductOptionFormData, Maintenance, Option } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { TagInput } from "@/components/ui/TagInput";
import { ArrowRight, Settings } from "lucide-react";
import { ArrowRight, Settings, Plus, Trash2 } from "lucide-react";
const maintenanceSchema = yup.object({
title: yup.string().required('عنوان نگهداری الزامی است'),
description: yup.string().required('توضیحات نگهداری الزامی است'),
content: yup.string().required('محتوای نگهداری الزامی است'),
image: yup.string().required('تصویر نگهداری الزامی است'),
});
const optionSchema = yup.object({
title: yup.string().required('عنوان گزینه الزامی است'),
description: yup.string().required('توضیحات گزینه الزامی است'),
meta_title: yup.string().required('متا تایتل الزامی است'),
});
const productOptionSchema = yup.object({
name: yup.string().required('نام گزینه الزامی است').min(2, 'نام گزینه باید حداقل 2 کاراکتر باشد'),
values: yup.array().of(yup.string()).min(1, 'حداقل یک مقدار باید وارد شود').required('مقادیر الزامی است'),
title: yup.string().required('عنوان الزامی است').min(2, 'عنوان باید حداقل 2 کاراکتر باشد'),
description: yup.string().required('توضیحات الزامی است'),
maintenance: maintenanceSchema.required('اطلاعات نگهداری الزامی است'),
options: yup.array().of(optionSchema).min(1, 'حداقل یک گزینه باید وارد شود').required('گزینه‌ها الزامی است'),
});
const ProductOptionFormPage = () => {
@ -30,7 +44,7 @@ const ProductOptionFormPage = () => {
const {
register,
handleSubmit,
formState: { errors, isValid, isDirty },
formState: { errors, isValid },
setValue,
watch,
control
@ -38,17 +52,31 @@ const ProductOptionFormPage = () => {
resolver: yupResolver(productOptionSchema) as any,
mode: 'onChange',
defaultValues: {
name: '',
values: []
title: '',
description: '',
maintenance: {
title: '',
description: '',
content: '',
image: ''
},
options: []
}
});
const { fields: optionFields, append: appendOption, remove: removeOption } = useFieldArray({
control,
name: "options"
});
const formValues = watch();
useEffect(() => {
if (isEdit && productOption) {
setValue('name', productOption.name, { shouldValidate: true });
setValue('values', productOption.values, { shouldValidate: true });
setValue('title', productOption.title, { shouldValidate: true });
setValue('description', productOption.description, { shouldValidate: true });
setValue('maintenance', productOption.maintenance, { shouldValidate: true });
setValue('options', productOption.options, { shouldValidate: true });
}
}, [isEdit, productOption, setValue]);
@ -56,18 +84,14 @@ const ProductOptionFormPage = () => {
if (isEdit && id) {
updateOption({
id: parseInt(id),
name: data.name,
values: data.values
...data
}, {
onSuccess: () => {
navigate('/product-options');
}
});
} else {
createOption({
name: data.name,
values: data.values
}, {
createOption(data, {
onSuccess: () => {
navigate('/product-options');
}
@ -75,122 +99,172 @@ const ProductOptionFormPage = () => {
}
};
const handleBack = () => {
navigate('/product-options');
const addOption = () => {
appendOption({
title: '',
description: '',
meta_title: ''
});
};
const handleValuesChange = (values: string[]) => {
setValue('values', values, { shouldValidate: true, shouldDirty: true });
};
if (isEdit && isLoadingOption) {
if (isLoadingOption) {
return (
<div className="flex justify-center items-center h-64">
<div className="flex items-center justify-center min-h-screen">
<LoadingSpinner />
</div>
);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={handleBack}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Settings className="h-6 w-6" />
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isEdit ? 'ویرایش اطلاعات گزینه محصول' : 'اطلاعات گزینه محصول جدید را وارد کنید'}
</p>
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={() => navigate('/product-options')}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
برگشت
</Button>
<div className="flex items-center gap-2">
<Settings className="h-6 w-6 text-primary-600" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'}
</h1>
</div>
</div>
</div>
</div>
{/* Form */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Input
label="نام گزینه"
{...register('name')}
error={errors.name?.message}
placeholder="مثال: رنگ، سایز، جنس"
/>
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Input
label="عنوان"
{...register('title')}
error={errors.title?.message}
placeholder="عنوان گزینه محصول را وارد کنید"
/>
</div>
<div>
<Input
label="توضیحات"
{...register('description')}
error={errors.description?.message}
placeholder="توضیحات گزینه محصول را وارد کنید"
/>
</div>
</div>
<TagInput
label="مقادیر گزینه"
values={watch('values') || []}
onChange={handleValuesChange}
placeholder="مقدار جدید اضافه کنید..."
error={errors.values?.message}
/>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">اطلاعات نگهداری</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="عنوان نگهداری"
{...register('maintenance.title')}
error={errors.maintenance?.title?.message}
placeholder="عنوان نگهداری را وارد کنید"
/>
<Input
label="توضیحات نگهداری"
{...register('maintenance.description')}
error={errors.maintenance?.description?.message}
placeholder="توضیحات نگهداری را وارد کنید"
/>
<Input
label="محتوای نگهداری"
{...register('maintenance.content')}
error={errors.maintenance?.content?.message}
placeholder="محتوای نگهداری را وارد کنید"
/>
<Input
label="تصویر نگهداری"
{...register('maintenance.image')}
error={errors.maintenance?.image?.message}
placeholder="آدرس تصویر نگهداری را وارد کنید"
/>
</div>
</div>
{/* Preview */}
{formValues.values && formValues.values.length > 0 && (
<div className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
پیشنمایش گزینه
</h3>
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>نام:</strong> {formValues.name || 'نام گزینه'}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">گزینهها</h3>
<Button
type="button"
variant="primary"
onClick={addOption}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
افزودن گزینه
</Button>
</div>
{optionFields.map((field, index) => (
<div key={field.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-md font-medium text-gray-900 dark:text-gray-100">گزینه {index + 1}</h4>
<Button
type="button"
variant="danger"
onClick={() => removeOption(index)}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
حذف
</Button>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>مقادیر:</strong>
<div className="flex flex-wrap gap-1 mt-1">
{formValues.values.map((value, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-md text-xs bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200"
>
{value}
</span>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="عنوان"
{...register(`options.${index}.title`)}
error={errors.options?.[index]?.title?.message}
placeholder="عنوان گزینه را وارد کنید"
/>
<Input
label="توضیحات"
{...register(`options.${index}.description`)}
error={errors.options?.[index]?.description?.message}
placeholder="توضیحات گزینه را وارد کنید"
/>
<Input
label="متا تایتل"
{...register(`options.${index}.meta_title`)}
error={errors.options?.[index]?.meta_title?.message}
placeholder="متا تایتل را وارد کنید"
/>
</div>
</div>
</div>
)}
))}
<div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
{optionFields.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
هیچ گزینهای تعریف نشده است. برای شروع گزینهای اضافه کنید.
</div>
)}
</div>
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
variant="secondary"
onClick={handleBack}
onClick={() => navigate('/product-options')}
disabled={isLoading}
>
انصراف
لغو
</Button>
<Button
type="submit"
loading={isLoading}
variant="primary"
disabled={!isValid || isLoading}
loading={isLoading}
>
{isEdit ? 'به‌روزرسانی' : 'ایجاد'}
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول'}
</Button>
</div>
</form>
</div>
{/* Help Section */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
راهنما
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> گزینههای محصول برای تعریف ویژگیهایی مثل رنگ، سایز، جنس استفاده میشوند</li>
<li> هر گزینه میتواند چندین مقدار داشته باشد (مثل قرمز، آبی، سبز برای رنگ)</li>
<li> این گزینهها بعداً در ایجاد محصولات قابل استفاده خواهند بود</li>
<li> برای اضافه کردن مقدار جدید، آن را تایپ کنید و Enter بزنید</li>
</ul>
</div>
</div>
);
};

View File

@ -163,22 +163,22 @@ const ProductOptionsListPage = () => {
{(productOptions || []).map((option: ProductOption) => (
<tr key={option.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{option.name}
{option.title}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
<div className="flex flex-wrap gap-1 max-w-xs">
{option.values.slice(0, 3).map((value, index) => (
{(option.options || []).slice(0, 3).map((optionItem, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-md text-xs bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200"
>
<Tag className="h-3 w-3 mr-1" />
{value}
{optionItem.title}
</span>
))}
{option.values.length > 3 && (
{(option.options || []).length > 3 && (
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
+{option.values.length - 3} بیشتر
+{(option.options || []).length - 3} بیشتر
</span>
)}
</div>
@ -218,21 +218,21 @@ const ProductOptionsListPage = () => {
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{option.name}
{option.title}
</h3>
<div className="flex flex-wrap gap-1 mt-2">
{option.values.slice(0, 3).map((value, index) => (
{(option.options || []).slice(0, 3).map((optionItem, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-md text-xs bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200"
>
<Tag className="h-3 w-3 mr-1" />
{value}
{optionItem.title}
</span>
))}
{option.values.length > 3 && (
{(option.options || []).length > 3 && (
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
+{option.values.length - 3} بیشتر
+{(option.options || []).length - 3} بیشتر
</span>
)}
</div>

View File

@ -195,8 +195,8 @@ const ProductFormPage = () => {
const productOptionOptions = (productOptions || []).map(option => ({
id: option.id,
title: option.name,
description: `مقادیر: ${option.values.join(', ')}`
title: option.title,
description: `تعداد گزینه‌ها: ${(option.options || []).length}`
}));
return (
@ -329,7 +329,7 @@ const ProductFormPage = () => {
<option value="">بدون گزینه</option>
{(productOptions || []).map((option) => (
<option key={option.id} value={option.id}>
{option.name} ({option.values.join(', ')})
{option.title} ({(option.options || []).length} گزینه)
</option>
))}
</select>

10101
swagger.json

File diff suppressed because it is too large Load Diff