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:
hossein taromi 2025-07-27 14:45:17 +03:30
parent dce0f918ef
commit 6bcdf72512
5 changed files with 1260 additions and 0 deletions

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 || "خطا در حذف فایل");
},
});
};