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