diff --git a/src/App.tsx b/src/App.tsx index 2246af2..8dcfeac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,6 +66,9 @@ const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage')) // Shipping Methods Pages const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage')); const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage')); +const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage')); +const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage')); +const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage')); const ProtectedRoute = ({ children }: { children: any }) => { const { user, isLoading } = useAuth(); @@ -147,6 +150,10 @@ const AppRoutes = () => { } /> } /> + } /> + } /> + } /> + {/* Products Routes */} } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 126085d..7c336bb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -16,7 +16,8 @@ import { ShoppingCart, Users, Truck, - X + X, + MessageSquare } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { PermissionWrapper } from '../common/PermissionWrapper'; @@ -28,6 +29,7 @@ interface MenuItem { path?: string; permission?: number; children?: MenuItem[]; + exact?: boolean; } const menuItems: MenuItem[] = [ @@ -51,6 +53,23 @@ const menuItems: MenuItem[] = [ icon: BadgePercent, path: '/discount-codes', }, + { + title: 'تیکت‌ها', + icon: MessageSquare, + children: [ + { + title: 'لیست تیکت‌ها', + icon: MessageSquare, + path: '/tickets', + exact: true, + }, + { + title: 'تنظیمات تیکت', + icon: Sliders, + path: '/tickets/config', + }, + ] + }, { title: 'مدیریت محصولات', icon: Package, @@ -159,6 +178,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { const menuContent = ( { // Close mobile menu when clicking a link if (window.innerWidth < 1024) { diff --git a/src/components/ui/FileUploader.tsx b/src/components/ui/FileUploader.tsx index 45befcb..ae7093e 100644 --- a/src/components/ui/FileUploader.tsx +++ b/src/components/ui/FileUploader.tsx @@ -15,7 +15,7 @@ export interface UploadedFile { } interface FileUploaderProps { - onUpload: (file: File) => Promise<{ id: string; url: string }>; + onUpload: (file: File) => Promise<{ id: string; url: string; mimeType?: string }>; onRemove?: (fileId: string) => void; acceptedTypes?: string[]; maxFileSize?: number; diff --git a/src/components/ui/JalaliDateTimePicker.tsx b/src/components/ui/JalaliDateTimePicker.tsx index afe8729..00560d0 100644 --- a/src/components/ui/JalaliDateTimePicker.tsx +++ b/src/components/ui/JalaliDateTimePicker.tsx @@ -17,7 +17,7 @@ interface JalaliDateTimePickerProps { const toIsoLike = (date?: DateObject | null): string | undefined => { if (!date) return undefined; try { - const g = date.convert(); + const g = date.convert(undefined); const yyyy = g.year.toString().padStart(4, '0'); const mm = g.month.toString().padStart(2, '0'); const dd = g.day.toString().padStart(2, '0'); diff --git a/src/components/ui/VariantManager.tsx b/src/components/ui/VariantManager.tsx index bcff4a8..6c78765 100644 --- a/src/components/ui/VariantManager.tsx +++ b/src/components/ui/VariantManager.tsx @@ -6,6 +6,7 @@ import { FileUploader } from './FileUploader'; import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload'; import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils'; import { API_GATE_WAY, API_ROUTES } from '@/constant/routes'; +import { toast } from "react-hot-toast"; const toPublicUrl = (img: any): ProductImage => { const rawUrl: string = img?.url || ''; @@ -16,15 +17,36 @@ const toPublicUrl = (img: any): ProductImage => { ? rawUrl : rawUrl ? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}` - : ''; + : ''; return { id: (img?.id ?? img).toString(), url, alt: img?.alt || '', order: img?.order ?? 0, + type: img?.mime_type || img?.type, }; }; +const IMAGE_MAX_SIZE = 2 * 1024 * 1024; +const VIDEO_MAX_SIZE = 25 * 1024 * 1024; + +const isImageFileType = (file: File) => file.type?.startsWith('image/'); +const isVideoFileType = (file: File) => file.type?.startsWith('video/'); + +const validateVariantMedia = (file: File) => { + if (isImageFileType(file)) { + if (file.size > IMAGE_MAX_SIZE) { + throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد'); + } + } else if (isVideoFileType(file)) { + if (file.size > VIDEO_MAX_SIZE) { + throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد'); + } + } else { + throw new Error('فقط تصاویر یا ویدیو مجاز است'); + } +}; + interface ProductOption { id: number; title: string; @@ -112,22 +134,21 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is const handleFileUpload = async (file: File) => { try { + validateVariantMedia(file); const result = await uploadFile(file); - - // Use functional update to avoid stale state when multiple files upload concurrently setUploadedImages(prev => { const newImage: ProductImage = { id: result.id, url: result.url, alt: file.name, - order: prev.length + order: prev.length, + type: result.mimeType || file.type }; return [...prev, newImage]; }); - return result; - } catch (error) { - console.error('Upload error:', error); + } catch (error: any) { + toast.error(error?.message || 'خطا در آپلود فایل'); throw error; } }; @@ -329,11 +350,11 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is {uploadedImages.length > 0 && ( @@ -341,11 +362,20 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is
{uploadedImages.map((image, index) => (
- {image.alt + {image.type?.startsWith('video') ? ( +
@@ -520,9 +664,18 @@ const ProductDetailPage = () => {
- {previewImage && ( - setPreviewImage(null)} title="پیش‌نمایش تصویر" size="xl"> - تصویر + {previewMedia && ( + setPreviewMedia(null)} + title={previewMedia.type === 'video' ? 'پیش‌نمایش ویدیو' : 'پیش‌نمایش تصویر'} + size="xl" + > + {previewMedia.type === 'video' ? ( + )} diff --git a/src/pages/products/product-form/ProductFormPage.tsx b/src/pages/products/product-form/ProductFormPage.tsx index 865a3f3..5c44323 100644 --- a/src/pages/products/product-form/ProductFormPage.tsx +++ b/src/pages/products/product-form/ProductFormPage.tsx @@ -14,10 +14,11 @@ import { Input } from "@/components/ui/Input"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { FileUploader } from "@/components/ui/FileUploader"; import { VariantManager } from "@/components/ui/VariantManager"; -import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react"; +import { ArrowRight, X } from "lucide-react"; import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography'; import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils'; import { API_GATE_WAY, API_ROUTES } from '@/constant/routes'; +import { toast } from "react-hot-toast"; const productSchema = yup.object({ name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'), @@ -34,11 +35,13 @@ const productSchema = yup.object({ product_option_id: yup.number().transform(createOptionalNumberTransform()).nullable(), file_ids: yup.array().of(yup.object()).default([]), variants: yup.array().default([]), + explorer_file_ids: yup.array().of(yup.object()).default([]), + is_delete_latest_explorer_files: yup.boolean().optional(), }); const toPublicUrl = (img: any): ProductImage => { - const rawUrl: string = img?.url || ''; - const serveKey: string | undefined = (img && img.serve_key) || undefined; + const rawUrl: string = img?.url || img?.file_url || img?.fileUrl || ''; + const serveKey: string | undefined = img?.serve_key || img?.serveKey; const url = serveKey ? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}` : rawUrl?.startsWith('http') @@ -47,13 +50,97 @@ const toPublicUrl = (img: any): ProductImage => { ? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}` : ''; return { - id: (img?.id ?? img).toString(), + id: (img?.id ?? img?.file_id ?? img?.FileID ?? img).toString(), url, - alt: img?.alt || '', + alt: img?.alt || img?.original_name || '', order: img?.order ?? 0, + type: img?.mime_type || img?.type || img?.file_type, + mime_type: img?.mime_type || img?.file_type, + size: img?.size, }; }; +const IMAGE_MAX_SIZE = 2 * 1024 * 1024; +const VIDEO_MAX_SIZE = 25 * 1024 * 1024; + +const isImageFile = (file: File) => file.type?.startsWith('image/'); +const isVideoFile = (file: File) => file.type?.startsWith('video/'); + +const ensureSquareImage = (file: File) => + new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + if (img.width === img.height) { + resolve(); + } else { + reject(new Error('ابعاد تصویر Explorer باید ۱ در ۱ باشد')); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('امکان بررسی ابعاد تصویر وجود ندارد')); + }; + img.src = url; + }); + +const validateMediaFile = async (file: File, options?: { requireSquare?: boolean }) => { + if (isImageFile(file)) { + if (file.size > IMAGE_MAX_SIZE) { + throw new Error('حجم تصویر نباید بیشتر از ۲ مگابایت باشد'); + } + if (options?.requireSquare) { + await ensureSquareImage(file); + } + } else if (isVideoFile(file)) { + if (file.size > VIDEO_MAX_SIZE) { + throw new Error('حجم ویدیو نباید بیشتر از ۲۵ مگابایت باشد'); + } + } else { + throw new Error('فقط تصاویر یا ویدیو مجاز است'); + } +}; + +const mapExplorerFiles = (entries: any[]): ProductImage[] => { + if (!entries || !Array.isArray(entries)) { + return []; + } + return entries.map((entry, index) => { + if (entry?.file) { + const media = toPublicUrl(entry.file); + return { ...media, order: index }; + } + if (entry?.file_id) { + const normalized = toPublicUrl({ + ...entry, + id: entry.file_id, + url: entry.file_url || entry.url, + file_url: entry.file_url || entry.url, + mime_type: entry.mime_type || entry.file_type, + type: entry.file_type || entry.type, + original_name: entry.name || entry.original_name, + }); + return { ...normalized, order: index }; + } + if (entry?.FileID) { + const normalized = toPublicUrl({ + ...entry, + id: entry.FileID, + url: entry.file_url || entry.url, + file_url: entry.file_url || entry.url, + }); + return { ...normalized, order: index }; + } + if (typeof entry === 'number' || typeof entry === 'string') { + const normalized = toPublicUrl(entry); + return { ...normalized, order: index }; + } + const normalized = toPublicUrl(entry); + return { ...normalized, order: index }; + }); +}; + const ProductFormPage = () => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); @@ -61,6 +148,9 @@ const ProductFormPage = () => { const [uploadedImages, setUploadedImages] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [explorerFiles, setExplorerFiles] = useState([]); + const [isExplorerUploading, setIsExplorerUploading] = useState(false); + const [isDeleteExplorerFiles, setIsDeleteExplorerFiles] = useState(false); const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit); const { data: categories, isLoading: isLoadingCategories } = useCategories(); @@ -94,7 +184,9 @@ const ProductFormPage = () => { category_ids: [], product_option_id: undefined, file_ids: [], - variants: [] + variants: [], + explorer_file_ids: [], + is_delete_latest_explorer_files: false } }); @@ -165,11 +257,20 @@ const ProductFormPage = () => { const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl); setUploadedImages(normalizedImages); setValue('file_ids', normalizedImages, { shouldValidate: true, shouldDirty: false }); + const explorerSource = + (product as any).explorer_files && (product as any).explorer_files.length > 0 + ? (product as any).explorer_files + : (product as any).explorer_file_ids || []; + const normalizedExplorer = mapExplorerFiles(explorerSource); + setExplorerFiles(normalizedExplorer); + setValue('explorer_file_ids', normalizedExplorer, { shouldValidate: true, shouldDirty: false }); + setIsDeleteExplorerFiles(false); } }, [isEdit, product, reset]); const handleFileUpload = async (file: File) => { try { + await validateMediaFile(file); const result = await uploadFile(file); setUploadedImages(prev => { @@ -177,7 +278,8 @@ const ProductFormPage = () => { id: result.id, url: result.url, alt: file.name, - order: prev.length + order: prev.length, + type: result.mimeType || file.type }; const updated = [...prev, newImage]; setValue('file_ids', updated, { shouldValidate: true, shouldDirty: true }); @@ -185,8 +287,8 @@ const ProductFormPage = () => { }); return result; - } catch (error) { - console.error('Upload error:', error); + } catch (error: any) { + toast.error(error?.message || 'خطا در آپلود فایل'); throw error; } }; @@ -198,6 +300,36 @@ const ProductFormPage = () => { deleteFile(fileId); }; + const handleExplorerUpload = async (file: File) => { + try { + await validateMediaFile(file, { requireSquare: isImageFile(file) }); + const result = await uploadFile(file); + setExplorerFiles(prev => { + const newImage: ProductImage = { + id: result.id, + url: result.url, + alt: file.name, + order: prev.length, + type: result.mimeType || file.type + }; + const updated = [...prev, newImage]; + setValue('explorer_file_ids', updated, { shouldValidate: true, shouldDirty: true }); + return updated; + }); + return result; + } catch (error: any) { + toast.error(error?.message || 'خطا در آپلود فایل'); + throw error; + } + }; + + const handleExplorerRemove = (fileId: string) => { + const updatedImages = explorerFiles.filter(img => img.id !== fileId); + setExplorerFiles(updatedImages); + setValue('explorer_file_ids', updatedImages, { shouldValidate: true, shouldDirty: true }); + deleteFile(fileId); + }; + const onSubmit = (data: any) => { @@ -210,6 +342,13 @@ const ProductFormPage = () => { }) .filter(id => id !== null); + const validExplorerIds = explorerFiles + .map(img => { + const numericId = Number(img.id); + return isNaN(numericId) ? null : numericId; + }) + .filter(id => id !== null); + const baseSubmitData = { name: convertedData.name, description: convertedData.description || '', @@ -221,7 +360,12 @@ const ProductFormPage = () => { attributes: {}, category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [], product_option_id: convertedData.product_option_id || null, - file_ids: validImageIds + file_ids: validImageIds, + }; + + const submitBaseData = { + ...baseSubmitData, + explorer_file_ids: validExplorerIds, }; console.log('Submitting product data:', baseSubmitData); @@ -246,8 +390,9 @@ const ProductFormPage = () => { updateProduct({ id: parseInt(id), - ...baseSubmitData, - variants: updateVariants + ...submitBaseData, + variants: updateVariants, + is_delete_latest_explorer_files: isDeleteExplorerFiles }, { onSuccess: () => { navigate('/products'); @@ -269,7 +414,7 @@ const ProductFormPage = () => { })) || []; createProduct({ - ...baseSubmitData, + ...submitBaseData, variants: createVariants }, { onSuccess: () => { @@ -449,12 +594,12 @@ const ProductFormPage = () => { setIsUploading(true)} onUploadComplete={() => setIsUploading(false)} /> @@ -467,11 +612,20 @@ const ProductFormPage = () => {
{uploadedImages.map((image, index) => (
- {image.alt + {image.type?.startsWith('video') ? ( +
+
+

+ فایل‌های Explorer +

+

+ تنها تصاویر مربعی پذیرفته می‌شوند و می‌توانید ویدیو نیز اضافه کنید. +

+ setIsExplorerUploading(true)} + onUploadComplete={() => setIsExplorerUploading(false)} + /> + {explorerFiles.length > 0 && ( +
+

+ فایل‌های Explorer ({explorerFiles.length}) +

+
+ {explorerFiles.map((file, index) => ( +
+ {file.url ? ( + file.type?.startsWith('video') ? ( +
+ ))} +
+
+ )} + {isEdit && ( +
+ setIsDeleteExplorerFiles(e.target.checked)} + /> + + حذف فایل‌های Explorer قبلی و جایگزینی با فایل‌های جدید + +
+ )} +
+ {/* Variants Management */}
{
{uploadedImages.length > 0 && ( - {formValues.name} + uploadedImages[0].type?.startsWith('video') ? ( +