feat(ui): add reusable UI components for product management
- Add MultiSelectAutocomplete for multi-selection with search - Add TagInput for managing array of string values - Add FileUploader with drag & drop and progress tracking - Add VariantManager for comprehensive product variant management - Add useFileUpload hooks for file upload/delete operations
This commit is contained in:
parent
dce0f918ef
commit
6bcdf72512
|
|
@ -0,0 +1,325 @@
|
|||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, X, Image, File, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
url?: string;
|
||||
preview?: string;
|
||||
progress: number;
|
||||
status: 'uploading' | 'completed' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileUploaderProps {
|
||||
onUpload: (file: File) => Promise<{ id: string; url: string }>;
|
||||
onRemove?: (fileId: string) => void;
|
||||
acceptedTypes?: string[];
|
||||
maxFileSize?: number; // در بایت
|
||||
maxFiles?: number;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
onUpload,
|
||||
onRemove,
|
||||
acceptedTypes = ['image/*', 'video/*'],
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
maxFiles = 10,
|
||||
label = "فایلها",
|
||||
description = "تصاویر و ویدیوها را اینجا بکشید یا کلیک کنید",
|
||||
error,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) => {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isImage = (type: string) => type.startsWith('image/');
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const validateFile = (file: File) => {
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
return `حجم فایل نباید بیشتر از ${formatFileSize(maxFileSize)} باشد`;
|
||||
}
|
||||
|
||||
if (acceptedTypes.length > 0) {
|
||||
const isAccepted = acceptedTypes.some(type => {
|
||||
if (type === 'image/*') return file.type.startsWith('image/');
|
||||
if (type === 'video/*') return file.type.startsWith('video/');
|
||||
return file.type === type;
|
||||
});
|
||||
if (!isAccepted) {
|
||||
return 'نوع فایل پشتیبانی نمیشود';
|
||||
}
|
||||
}
|
||||
|
||||
if (maxFiles && files.length >= maxFiles) {
|
||||
return `حداکثر ${maxFiles} فایل مجاز است`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const createFilePreview = (file: File) => {
|
||||
return new Promise<string>((resolve) => {
|
||||
if (isImage(file.type)) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
resolve('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
const errorFile: UploadedFile = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
progress: 0,
|
||||
status: 'error',
|
||||
error: validationError,
|
||||
};
|
||||
setFiles(prev => [...prev, errorFile]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileId = Math.random().toString(36).substr(2, 9);
|
||||
const preview = await createFilePreview(file);
|
||||
|
||||
const newFile: UploadedFile = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
};
|
||||
|
||||
setFiles(prev => [...prev, newFile]);
|
||||
|
||||
try {
|
||||
// شبیهسازی پروگرس
|
||||
const progressInterval = setInterval(() => {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === fileId && f.progress < 90
|
||||
? { ...f, progress: f.progress + 10 }
|
||||
: f
|
||||
));
|
||||
}, 200);
|
||||
|
||||
const result = await onUpload(file);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === fileId
|
||||
? { ...f, progress: 100, status: 'completed', url: result.url, id: result.id }
|
||||
: f
|
||||
));
|
||||
} catch (error: any) {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === fileId
|
||||
? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' }
|
||||
: f
|
||||
));
|
||||
}
|
||||
}, [onUpload, files.length, maxFiles, maxFileSize, acceptedTypes]);
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList) => {
|
||||
Array.from(selectedFiles).forEach(file => {
|
||||
handleFileUpload(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (disabled) return;
|
||||
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
handleFileSelect(droppedFiles);
|
||||
}, [disabled, handleFileUpload]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setIsDragOver(true);
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = (fileId: string) => {
|
||||
setFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
onRemove?.(fileId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
|
||||
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
|
||||
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedTypes.join(',')}
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Files List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
فایلهای آپلود شده ({files.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
{/* File Icon/Preview */}
|
||||
<div className="flex-shrink-0">
|
||||
{file.preview ? (
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded flex items-center justify-center">
|
||||
{isImage(file.type) ? (
|
||||
<Image className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<File className="h-5 w-5 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{file.status === 'uploading' && (
|
||||
<div className="mt-1">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-primary-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{file.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{file.status === 'error' && file.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{file.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{file.status === 'completed' && (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(file.id);
|
||||
}}
|
||||
className="p-1 h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, X, Search } from 'lucide-react';
|
||||
|
||||
export interface Option {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MultiSelectAutocompleteProps {
|
||||
options: Option[];
|
||||
selectedValues: number[];
|
||||
onChange: (values: number[]) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = ({
|
||||
options,
|
||||
selectedValues,
|
||||
onChange,
|
||||
placeholder = "انتخاب کنید...",
|
||||
label,
|
||||
error,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredOptions = options.filter(option =>
|
||||
option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const selectedOptions = options.filter(option => selectedValues.includes(option.id));
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggleOption = (optionId: number) => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
onChange(selectedValues.filter(id => id !== optionId));
|
||||
} else {
|
||||
onChange([...selectedValues, optionId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOption = (optionId: number) => {
|
||||
onChange(selectedValues.filter(id => id !== optionId));
|
||||
};
|
||||
|
||||
const handleToggleDropdown = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Selected Items Display */}
|
||||
<div
|
||||
className={`
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
cursor-pointer
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
dark:text-gray-100
|
||||
`}
|
||||
onClick={handleToggleDropdown}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{selectedOptions.length > 0 ? (
|
||||
selectedOptions.map(option => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-800 text-xs rounded-md"
|
||||
>
|
||||
{option.title}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveOption(option.id);
|
||||
}}
|
||||
className="hover:bg-primary-200 rounded-full p-0.5"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400">{placeholder}</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-[60px]">
|
||||
{isOpen && !disabled && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full border-none outline-none bg-transparent text-sm"
|
||||
placeholder="جستجو..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
) : filteredOptions.length > 0 ? (
|
||||
filteredOptions.map(option => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={`
|
||||
px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
${selectedValues.includes(option.id) ? 'bg-primary-50 dark:bg-primary-900/20' : ''}
|
||||
`}
|
||||
onClick={() => handleToggleOption(option.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{option.title}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedValues.includes(option.id) && (
|
||||
<div className="text-primary-600 dark:text-primary-400">✓</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-center text-gray-500 dark:text-gray-400">
|
||||
موردی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface TagInputProps {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TagInput: React.FC<TagInputProps> = ({
|
||||
values,
|
||||
onChange,
|
||||
placeholder = "اضافه کنید و Enter بزنید...",
|
||||
label,
|
||||
error,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const addValue = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue && !values.includes(trimmedValue)) {
|
||||
onChange([...values, trimmedValue]);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
const newValues = values.filter((_, i) => i !== index);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addValue(inputValue);
|
||||
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
|
||||
removeValue(values.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (inputValue.trim()) {
|
||||
addValue(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{values.map((value, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-800 text-sm rounded-md"
|
||||
>
|
||||
{value}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeValue(index)}
|
||||
className="hover:bg-primary-200 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={values.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] border-none outline-none bg-transparent text-sm dark:text-gray-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!disabled && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter بزنید یا روی جای دیگری کلیک کنید تا مقدار اضافه شود
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Plus, Trash2, Edit3, Package } from 'lucide-react';
|
||||
import { ProductVariantFormData, ProductImage } from '../../pages/products/core/_models';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { FileUploader } from './FileUploader';
|
||||
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
|
||||
|
||||
interface VariantManagerProps {
|
||||
variants: ProductVariantFormData[];
|
||||
onChange: (variants: ProductVariantFormData[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface VariantFormProps {
|
||||
variant?: ProductVariantFormData;
|
||||
onSave: (variant: ProductVariantFormData) => void;
|
||||
onCancel: () => void;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false }) => {
|
||||
const [formData, setFormData] = useState<ProductVariantFormData>(
|
||||
variant || {
|
||||
enabled: true,
|
||||
fee_percentage: 0,
|
||||
profit_percentage: 0,
|
||||
stock_limit: 0,
|
||||
stock_managed: true,
|
||||
stock_number: 0,
|
||||
weight: 0,
|
||||
attributes: {},
|
||||
meta: {},
|
||||
images: []
|
||||
}
|
||||
);
|
||||
|
||||
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>(variant?.images || []);
|
||||
const [attributes, setAttributes] = useState<Record<string, any>>(variant?.attributes || {});
|
||||
const [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
|
||||
const [newAttributeKey, setNewAttributeKey] = useState('');
|
||||
const [newAttributeValue, setNewAttributeValue] = useState('');
|
||||
const [newMetaKey, setNewMetaKey] = useState('');
|
||||
const [newMetaValue, setNewMetaValue] = useState('');
|
||||
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
||||
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const newImage: ProductImage = {
|
||||
id: result.id,
|
||||
url: result.url,
|
||||
alt: file.name,
|
||||
order: uploadedImages.length
|
||||
};
|
||||
|
||||
const updatedImages = [...uploadedImages, newImage];
|
||||
setUploadedImages(updatedImages);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileRemove = (fileId: string) => {
|
||||
const updatedImages = uploadedImages.filter(img => img.id !== fileId);
|
||||
setUploadedImages(updatedImages);
|
||||
deleteFile(fileId);
|
||||
};
|
||||
|
||||
const handleAddAttribute = () => {
|
||||
if (newAttributeKey.trim() && newAttributeValue.trim()) {
|
||||
const updatedAttributes = {
|
||||
...attributes,
|
||||
[newAttributeKey.trim()]: newAttributeValue.trim()
|
||||
};
|
||||
setAttributes(updatedAttributes);
|
||||
setNewAttributeKey('');
|
||||
setNewAttributeValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAttribute = (key: string) => {
|
||||
const updatedAttributes = { ...attributes };
|
||||
delete updatedAttributes[key];
|
||||
setAttributes(updatedAttributes);
|
||||
};
|
||||
|
||||
const handleAddMeta = () => {
|
||||
if (newMetaKey.trim() && newMetaValue.trim()) {
|
||||
const updatedMeta = {
|
||||
...meta,
|
||||
[newMetaKey.trim()]: newMetaValue.trim()
|
||||
};
|
||||
setMeta(updatedMeta);
|
||||
setNewMetaKey('');
|
||||
setNewMetaValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMeta = (key: string) => {
|
||||
const updatedMeta = { ...meta };
|
||||
delete updatedMeta[key];
|
||||
setMeta(updatedMeta);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const variantToSave: ProductVariantFormData = {
|
||||
...formData,
|
||||
images: uploadedImages,
|
||||
attributes,
|
||||
meta
|
||||
};
|
||||
onSave(variantToSave);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEdit ? 'بهروزرسانی' : 'افزودن'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
درصد کارمزد
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.fee_percentage}
|
||||
onChange={(e) => handleInputChange('fee_percentage', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
درصد سود
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.profit_percentage}
|
||||
onChange={(e) => handleInputChange('profit_percentage', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وزن (گرم)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.weight}
|
||||
onChange={(e) => handleInputChange('weight', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Management */}
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
مدیریت موجودی
|
||||
</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.stock_managed}
|
||||
onChange={(e) => handleInputChange('stock_managed', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
مدیریت موجودی فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تعداد موجودی
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.stock_number}
|
||||
onChange={(e) => handleInputChange('stock_number', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
disabled={!formData.stock_managed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حد کمینه موجودی
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.stock_limit}
|
||||
onChange={(e) => handleInputChange('stock_limit', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
disabled={!formData.stock_managed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
تصاویر Variant
|
||||
</h5>
|
||||
<FileUploader
|
||||
onUpload={handleFileUpload}
|
||||
onRemove={handleFileRemove}
|
||||
acceptedTypes={['image/*']}
|
||||
maxFileSize={5 * 1024 * 1024}
|
||||
maxFiles={5}
|
||||
label=""
|
||||
description="تصاویر مخصوص این variant را آپلود کنید"
|
||||
/>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{uploadedImages.map((image, index) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt || `تصویر ${index + 1}`}
|
||||
className="w-full h-20 object-cover rounded-lg border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileRemove(image.id)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
ویژگیهای Variant
|
||||
</h5>
|
||||
|
||||
<div className="flex gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeKey}
|
||||
onChange={(e) => setNewAttributeKey(e.target.value)}
|
||||
placeholder="نام ویژگی (مثل: رنگ، سایز)"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder="مقدار (مثل: قرمز، بزرگ)"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddAttribute}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(attributes).length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border">
|
||||
<span className="text-sm">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAttribute(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta Data */}
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Meta Data
|
||||
</h5>
|
||||
|
||||
<div className="flex gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newMetaKey}
|
||||
onChange={(e) => setNewMetaKey(e.target.value)}
|
||||
placeholder="کلید Meta"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newMetaValue}
|
||||
onChange={(e) => setNewMetaValue(e.target.value)}
|
||||
placeholder="مقدار Meta"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddMeta}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(meta).length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(meta).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border">
|
||||
<span className="text-sm">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMeta(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => handleInputChange('enabled', e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Variant فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false }) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
const handleAddVariant = () => {
|
||||
setEditingIndex(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEditVariant = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteVariant = (index: number) => {
|
||||
const updatedVariants = variants.filter((_, i) => i !== index);
|
||||
onChange(updatedVariants);
|
||||
};
|
||||
|
||||
const handleSaveVariant = (variant: ProductVariantFormData) => {
|
||||
if (editingIndex !== null) {
|
||||
// Edit existing variant
|
||||
const updatedVariants = [...variants];
|
||||
updatedVariants[editingIndex] = variant;
|
||||
onChange(updatedVariants);
|
||||
} else {
|
||||
// Add new variant
|
||||
onChange([...variants, variant]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Variants محصول ({variants.length})
|
||||
</h3>
|
||||
{!disabled && !showForm && (
|
||||
<Button onClick={handleAddVariant} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن Variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show Form */}
|
||||
{showForm && (
|
||||
<VariantForm
|
||||
variant={editingIndex !== null ? variants[editingIndex] : undefined}
|
||||
onSave={handleSaveVariant}
|
||||
onCancel={handleCancelForm}
|
||||
isEdit={editingIndex !== null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Variants List */}
|
||||
{variants.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{variants.map((variant, index) => (
|
||||
<div key={index} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Variant {index + 1}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${variant.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{variant.enabled ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<strong>درصد کارمزد:</strong> {variant.fee_percentage}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>درصد سود:</strong> {variant.profit_percentage}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>وزن:</strong> {variant.weight} گرم
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variant.images && variant.images.length > 0 && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{variant.images.slice(0, 3).map((image, imgIndex) => (
|
||||
<img
|
||||
key={image.id}
|
||||
src={image.url}
|
||||
alt={image.alt || `تصویر ${imgIndex + 1}`}
|
||||
className="w-12 h-12 object-cover rounded border"
|
||||
/>
|
||||
))}
|
||||
{variant.images.length > 3 && (
|
||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-600 rounded border flex items-center justify-center text-xs">
|
||||
+{variant.images.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Attributes if any */}
|
||||
{Object.keys(variant.attributes).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ویژگیها:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(variant.attributes).map(([key, value]) => (
|
||||
<span key={key} className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEditVariant(index)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteVariant(index)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variants.length === 0 && !showForm && (
|
||||
<div className="text-center py-8 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
هنوز هیچ Variant ای اضافه نشده
|
||||
</p>
|
||||
{!disabled && (
|
||||
<Button onClick={handleAddVariant} className="flex items-center gap-2 mx-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن اولین Variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
httpPostRequest,
|
||||
httpDeleteRequest,
|
||||
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;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
file: UploadFileResponse;
|
||||
}
|
||||
|
||||
export const useFileUpload = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (file: File): Promise<{ id: string; url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
console.log("Uploading file:", file.name);
|
||||
|
||||
const response = await httpPostRequest<UploadResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPLOAD_FILE),
|
||||
formData
|
||||
);
|
||||
|
||||
console.log("Upload response:", response);
|
||||
|
||||
if (!response.data?.file) {
|
||||
throw new Error("Invalid upload response");
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.data.file.id,
|
||||
url: response.data.file.url,
|
||||
};
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("File upload error:", error);
|
||||
toast.error(error?.message || "خطا در آپلود فایل");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useFileDelete = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (fileId: string) => {
|
||||
const response = await httpDeleteRequest(
|
||||
APIUrlGenerator(API_ROUTES.DELETE_FILE(fileId))
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("فایل با موفقیت حذف شد");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("File delete error:", error);
|
||||
toast.error(error?.message || "خطا در حذف فایل");
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue