From 6df92f03d7af3a97dab4dd8b74e9eb53dcf2449f Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 17 Aug 2026 11:46:49 +0330 Subject: [PATCH] feat: complete settings sync, standard UI inputs, prescriptions flow, pet profile and checkout enhancements --- backend/src/admin/pets.service.ts | 10 + .../src/prescriptions/prescriptions.module.ts | 3 +- .../prescriptions/prescriptions.service.ts | 43 +- backend/src/products/products.service.ts | 21 +- backend/src/settings/settings.service.ts | 56 +- .../admin-panel/src/components/Topbar.tsx | 56 +- .../admin-panel/src/components/ui/Badge.tsx | 39 + .../src/components/ui/FormField.tsx | 58 + .../src/components/ui/ImagePreviewModal.tsx | 92 + .../admin-panel/src/components/ui/Input.tsx | 46 + .../src/components/ui/MediaSelector.tsx | 193 +- .../admin-panel/src/components/ui/Select.tsx | 52 + .../src/components/ui/Textarea.tsx | 33 + .../src/components/ui/ToggleSwitch.tsx | 50 + frontend/admin-panel/src/pages/Media.tsx | 60 +- frontend/admin-panel/src/pages/Pets.tsx | 238 +- .../src/pages/PrescriptionsManager.tsx | 336 +- frontend/admin-panel/src/pages/UITexts.tsx | 120 +- .../application/components/CheckoutPage.tsx | 46 +- frontend/application/components/Hero.tsx | 33 +- .../application/components/PetProfile.tsx | 357 +- .../components/PrescriptionUploadModal.tsx | 303 +- .../application/components/UserDashboard.tsx | 133 +- frontend/application/lib/services/api.ts | 1 + frontend/application/lib/store/usePetStore.ts | 2 + graphify-out/.graphify_analysis.json | 3466 +- .../2026-08-17/.graphify_analysis.json | 3533 +- graphify-out/2026-08-17/graph.json | 59660 ++++++++-------- graphify-out/2026-08-17/manifest.json | 826 +- graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.json | 50129 ++++++------- graphify-out/manifest.json | 906 +- 32 files changed, 61751 insertions(+), 59152 deletions(-) create mode 100644 frontend/admin-panel/src/components/ui/Badge.tsx create mode 100644 frontend/admin-panel/src/components/ui/FormField.tsx create mode 100644 frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx create mode 100644 frontend/admin-panel/src/components/ui/Input.tsx create mode 100644 frontend/admin-panel/src/components/ui/Select.tsx create mode 100644 frontend/admin-panel/src/components/ui/Textarea.tsx create mode 100644 frontend/admin-panel/src/components/ui/ToggleSwitch.tsx diff --git a/backend/src/admin/pets.service.ts b/backend/src/admin/pets.service.ts index 68ab117..747a6ac 100644 --- a/backend/src/admin/pets.service.ts +++ b/backend/src/admin/pets.service.ts @@ -36,6 +36,16 @@ export class PetsService { mobile: true, }, }, + medicalConditions: true, + reminders: true, + healthLogs: { + orderBy: { createdAt: 'desc' }, + take: 10, + }, + prescriptions: { + orderBy: { createdAt: 'desc' }, + take: 5, + }, }, }), this.prisma.pet.count({ where }), diff --git a/backend/src/prescriptions/prescriptions.module.ts b/backend/src/prescriptions/prescriptions.module.ts index 8e821aa..8a7d37e 100644 --- a/backend/src/prescriptions/prescriptions.module.ts +++ b/backend/src/prescriptions/prescriptions.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { PrescriptionsController } from './prescriptions.controller'; import { PrescriptionsService } from './prescriptions.service'; import { PrismaModule } from '../prisma/prisma.module'; +import { SmsService } from '../common/services/sms.service'; @Module({ imports: [PrismaModule], controllers: [PrescriptionsController], - providers: [PrescriptionsService], + providers: [PrescriptionsService, SmsService], exports: [PrescriptionsService], }) export class PrescriptionsModule {} diff --git a/backend/src/prescriptions/prescriptions.service.ts b/backend/src/prescriptions/prescriptions.service.ts index d21fb7b..379b1ec 100644 --- a/backend/src/prescriptions/prescriptions.service.ts +++ b/backend/src/prescriptions/prescriptions.service.ts @@ -2,18 +2,29 @@ import { Injectable, NotFoundException, ForbiddenException, + Logger, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { SmsService } from '../common/services/sms.service'; @Injectable() export class PrescriptionsService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(PrescriptionsService.name); + + constructor( + private prisma: PrismaService, + private smsService: SmsService, + ) {} async create( userId: string | null, data: { petId?: string; petName?: string; + petType?: string; + petBreed?: string; + petAge?: number; + petWeight?: number; phone?: string; fileUrl: string; notes?: string; @@ -30,7 +41,6 @@ export class PrescriptionsService { }); if (!user) { - // Create user with default role Customer user = await this.prisma.user.create({ data: { mobile: cleanPhone, @@ -57,10 +67,10 @@ export class PrescriptionsService { data: { userId: user.id, name: cleanPetName, - type: 'سگ', // Default pet type for prescription consultation - breed: 'نامشخص', - age: 1, - weight: 5.0, + type: data.petType || 'سگ', + breed: data.petBreed || 'نامشخص', + age: data.petAge !== undefined ? data.petAge : 0, + weight: data.petWeight !== undefined ? data.petWeight : 0, activityLevel: 'متوسط', }, }); @@ -119,11 +129,15 @@ export class PrescriptionsService { adminNotes?: string; }, ) { - const item = await this.prisma.prescription.findUnique({ where: { id } }); + const item = await this.prisma.prescription.findUnique({ + where: { id }, + include: { user: true, pet: true }, + }); if (!item) { throw new NotFoundException(`Prescription with ID ${id} not found`); } - return this.prisma.prescription.update({ + + const updated = await this.prisma.prescription.update({ where: { id }, data: { status: reviewData.status, @@ -131,5 +145,18 @@ export class PrescriptionsService { }, include: { user: true, pet: true }, }); + + // Send SMS notification if prescription was reviewed/approved and user has mobile + if (item.user?.mobile && reviewData.status === 'APPROVED') { + const petTitle = item.pet?.name ? ` برای «${item.pet.name}»` : ''; + const smsText = `کنینا: نسخه پزشکی شما${petTitle} توسط تیم تخصصی بررسی و تایید شد. جهت مشاهده تجویز پزشک و سفارش مکمل‌ها به پنل کاربری خود در canina.ir مراجعه کنید.`; + try { + await this.smsService.sendSms(item.user.mobile, smsText); + } catch (smsErr) { + this.logger.warn(`Failed to send prescription SMS to ${item.user.mobile}:`, smsErr); + } + } + + return updated; } } diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index cd73698..3b3b7c0 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -126,12 +126,29 @@ export class ProductsService { } async findOne(idOrSlug: string, userRole?: string) { + if (!idOrSlug) return null; + const decoded = decodeURIComponent(idOrSlug).trim(); + const cleanId = idOrSlug.trim(); const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - idOrSlug, + cleanId, ); + const product = await this.prisma.product.findFirst({ - where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, + where: isUuid + ? { id: cleanId } + : { + OR: [ + { slug: cleanId }, + { slug: decoded }, + { artNo: cleanId }, + { artNo: decoded }, + { nameFa: cleanId }, + { nameFa: decoded }, + { nameEn: cleanId }, + { nameEn: decoded }, + ], + }, include: { ingredientList: true, symptoms: true, diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts index 8f39540..d815d39 100644 --- a/backend/src/settings/settings.service.ts +++ b/backend/src/settings/settings.service.ts @@ -300,6 +300,24 @@ export class SettingsService implements OnModuleInit { } async getCategorySetting(category: string) { + if (category === 'system') { + const texts = await this.getUiTexts(); + const map = new Map(); + texts.forEach((t) => map.set(t.key, t.value)); + + const isMaintenance = map.get('MAINTENANCE_MODE') === 'true' || map.get('maintenance_mode') === 'true' || map.get('maintenanceMode') === 'true'; + const allowGuestCheckout = map.get('allowGuestCheckout') !== 'false'; + const b2bRegistrationOpen = map.get('b2bRegistrationOpen') !== 'false' && map.get('b2b_enabled') !== 'false'; + const supportPhone = map.get('CONTACT_PHONE') || map.get('contact_phone') || map.get('supportPhone') || '۰۲۱-۸۸۸۸۴۴۴۴'; + + return { + maintenanceMode: isMaintenance, + allowGuestCheckout, + b2bRegistrationOpen, + supportPhone, + }; + } + const setting = await this.prisma.setting.findFirst({ where: { category }, }); @@ -317,23 +335,29 @@ export class SettingsService implements OnModuleInit { // If system config is updated, also sync individual keys into uiText for fast & reliable lookup if (category === 'system' && typeof value === 'object' && value !== null) { const obj = value as Record; - const promises: Promise[] = []; - if ('b2bRegistrationOpen' in obj) { - const valStr = String(obj.b2bRegistrationOpen); - promises.push( - this.prisma.uiText.upsert({ - where: { key: 'b2bRegistrationOpen' }, - update: { value: valStr }, - create: { key: 'b2bRegistrationOpen', value: valStr }, - }), - this.prisma.uiText.upsert({ - where: { key: 'b2b_enabled' }, - update: { value: valStr }, - create: { key: 'b2b_enabled', value: valStr }, - }), - ); + const updates: Record = {}; + + if ('maintenanceMode' in obj) { + const valStr = String(Boolean(obj.maintenanceMode)); + updates['MAINTENANCE_MODE'] = valStr; + updates['maintenance_mode'] = valStr; + updates['maintenanceMode'] = valStr; } - await Promise.all(promises); + if ('supportPhone' in obj) { + updates['CONTACT_PHONE'] = String(obj.supportPhone); + updates['contact_phone'] = String(obj.supportPhone); + updates['supportPhone'] = String(obj.supportPhone); + } + if ('b2bRegistrationOpen' in obj) { + const valStr = String(Boolean(obj.b2bRegistrationOpen)); + updates['b2bRegistrationOpen'] = valStr; + updates['b2b_enabled'] = valStr; + } + if ('allowGuestCheckout' in obj) { + updates['allowGuestCheckout'] = String(Boolean(obj.allowGuestCheckout)); + } + + await this.updateBulkUiTexts(updates); } return res; diff --git a/frontend/admin-panel/src/components/Topbar.tsx b/frontend/admin-panel/src/components/Topbar.tsx index 1840742..e89b9ff 100644 --- a/frontend/admin-panel/src/components/Topbar.tsx +++ b/frontend/admin-panel/src/components/Topbar.tsx @@ -10,21 +10,36 @@ interface TopbarProps { } const SEARCHABLE_PAGES = [ - { label: 'داشبورد (خلاصه وضعیت فروشگاه)', path: '/' }, - { label: 'گزارشات کامل فروش و بازدیدها', path: '/reports' }, - { label: 'مدیریت سفارشات مشتریان', path: '/orders' }, - { label: 'مدیریت محصولات فروشگاه', path: '/products' }, - { label: 'دسته‌بندی‌های محصولات (Categories)', path: '/categories' }, - { label: 'کدهای تخفیف و کوپن‌های خرید', path: '/coupons' }, - { label: 'مدیریت کاربران و مشتریان سایت', path: '/users' }, - { label: 'مدیریت سگ‌ها و گربه‌ها (Pets)', path: '/pets' }, - { label: 'مدیریت مقالات وبلاگ', path: '/blogs' }, - { label: 'مدیریت دانشنامه و مقالات علمی (Wiki)', path: '/wiki' }, - { label: 'تنظیمات کلی سیستم و درگاه پرداخت', path: '/settings' }, - { label: 'مدیریت گواهی امنیتی SSL و HTTPS', path: '/settings/ssl' }, - { label: 'تنظیمات درگاه پیامک (MeliPayamak)', path: '/settings/sms' }, - { label: 'متون رابط کاربری و ترجمه‌ها', path: '/ui-texts' }, - { label: 'مدیریت رسانه، عکس‌ها و گالری', path: '/media' }, + { label: 'داشبورد (خلاصه وضعیت فروشگاه)', path: '/', keywords: ['dashboard', 'آمار', 'فروش', 'خانه'] }, + { label: 'مدیریت نسخه‌های پزشکی (Prescriptions)', path: '/prescriptions', keywords: ['نسخه', 'نسخه‌ها', 'پزشک', 'دارو', 'تجویز', 'rx', 'prescription', 'بررسی نسخه'] }, + { label: 'مدیریت سفارشات مشتریان', path: '/orders', keywords: ['سفارش', 'سفارشات', 'orders', 'خرید', 'فاکتور'] }, + { label: 'تراکنش‌های مالی و کیف پول', path: '/transactions', keywords: ['تراکنش', 'مالی', 'کیف پول', 'پرداخت', 'واریز', 'transactions'] }, + { label: 'مدیریت محصولات فروشگاه', path: '/products', keywords: ['محصول', 'محصولات', 'کالا', 'مکمل', 'products', 'قیمت'] }, + { label: 'دسته‌بندی‌های محصولات (Categories)', path: '/categories', keywords: ['دسته', 'دسته‌بندی', 'categories', 'گروه'] }, + { label: 'کدهای تخفیف و کوپن‌های خرید', path: '/coupons', keywords: ['تخفیف', 'کوپن', 'کد تخفیف', 'coupons', 'آفر'] }, + { label: 'مدیریت کاربران و مشتریان سایت', path: '/users', keywords: ['کاربر', 'کاربران', 'مشتری', 'users', 'پروفایل'] }, + { label: 'مدیریت سگ‌ها و گربه‌ها (پرونده پت‌ها)', path: '/pets', keywords: ['پت', 'حیوان', 'سگ', 'گربه', 'شناسنامه', 'pets'] }, + { label: 'درخواست‌های همکاری سازمانی و B2B', path: '/b2b', keywords: ['b2b', 'همکاری', 'همکاران', 'عمده', 'پت شاپ', 'کلینیک'] }, + { label: 'مشاور هوشمند تغذیه و درمان', path: '/smart-advisor', keywords: ['مشاور', 'هوشمند', 'تغذیه', 'درمان', 'advisor', 'علائم'] }, + { label: 'نظرات و دیدگاه‌های مشتریان', path: '/reviews', keywords: ['نظرات', 'دیدگاه', 'کامنت', 'امتیاز', 'reviews'] }, + { label: 'تیکت‌ها و پشتیبانی', path: '/tickets', keywords: ['تیکت', 'پشتیبانی', 'پیام', 'tickets', 'چت'] }, + { label: 'فرم‌های تماس با ما', path: '/contact', keywords: ['تماس', 'پیام تماس', 'ارتباط', 'contact'] }, + { label: 'مدیریت بنرهای تبلیغاتی و اسلایدر', path: '/banners', keywords: ['بنر', 'اسلایدر', 'تبلیغات', 'banners', 'هیرو'] }, + { label: 'مدیریت مقالات وبلاگ', path: '/blogs', keywords: ['وبلاگ', 'مقاله', 'بلاگ', 'آموزش', 'blogs'] }, + { label: 'مدیریت دانشنامه و مقالات علمی (Wiki)', path: '/wiki', keywords: ['ویکی', 'دانشنامه', 'علمی', 'پژوهش', 'wiki'] }, + { label: 'مدیریت ویدیوها و فیلم‌های آموزشی', path: '/videos', keywords: ['ویدیو', 'فیلم', 'کلیپ', 'آپارات', 'videos'] }, + { label: 'مدیریت مواد تشکیل‌دهنده و ترکیبات', path: '/ingredients', keywords: ['مواد', 'ترکیبات', 'گیاهان', 'ingredients'] }, + { label: 'مدیریت رضایت‌نامه‌ها و تجربیات (Testimonials)', path: '/testimonials', keywords: ['رضایت', 'تجربه', 'داستان', 'testimonials'] }, + { label: 'صفحات ایستا و CMS', path: '/cms', keywords: ['صفحات', 'قوانین', 'درباره ما', 'cms', 'تماس'] }, + { label: 'مدیریت رسانه، عکس‌ها و گالری', path: '/media', keywords: ['رسانه', 'عکس', 'تصویر', 'گالری', 'media', 'آپلود'] }, + { label: 'گزارشات کامل فروش و بازدیدها', path: '/reports', keywords: ['گزارش', 'نمودار', 'آمار', 'تحلیل', 'reports'] }, + { label: 'تنظیمات کلی سیستم و برند', path: '/settings', keywords: ['تنظیمات', 'لوگو', 'برند', 'settings', 'پیکربندی'] }, + { label: 'تنظیمات مالی و ارسال', path: '/settings/financial', keywords: ['تنظیمات مالی', 'هزینه ارسال', 'ارسال رایگان', 'مالیات', 'خیریه'] }, + { label: 'تنظیمات سیستمی و حالت تعمیرات', path: '/settings/system', keywords: ['حالت تعمیر', 'تعمیرات', 'سیستم', 'خرید مهمان', 'system'] }, + { label: 'تنظیمات سئو (SEO) و متاتگ‌ها', path: '/settings/seo', keywords: ['سئو', 'موتور جستجو', 'گوگل', 'seo', 'متاتگ'] }, + { label: 'تنظیمات درگاه پیامک (MeliPayamak)', path: '/settings/sms', keywords: ['پیامک', 'اس ام اس', 'سامانه پیامک', 'ملی پیامک', 'sms'] }, + { label: 'مدیریت گواهی امنیتی SSL و HTTPS', path: '/settings/ssl', keywords: ['ssl', 'امنیت', 'https', 'گواهی'] }, + { label: 'متون رابط کاربری و ترجمه‌ها', path: '/ui-texts', keywords: ['متون', 'ترجمه', 'رابط کاربری', 'ui', 'texts'] }, ]; export default function Topbar({ toggleMenu }: TopbarProps) { @@ -45,9 +60,14 @@ export default function Topbar({ toggleMenu }: TopbarProps) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const filteredResults = SEARCHABLE_PAGES.filter(page => - page.label.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredResults = normalizedQuery + ? SEARCHABLE_PAGES.filter(page => + page.label.toLowerCase().includes(normalizedQuery) || + page.path.toLowerCase().includes(normalizedQuery) || + page.keywords.some(k => k.toLowerCase().includes(normalizedQuery)) + ) + : []; const handleResultClick = (path: string) => { navigate(path); diff --git a/frontend/admin-panel/src/components/ui/Badge.tsx b/frontend/admin-panel/src/components/ui/Badge.tsx new file mode 100644 index 0000000..354ebe0 --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Badge.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +export type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'purple' | 'gray'; + +export interface BadgeProps { + variant?: BadgeVariant; + children: React.ReactNode; + icon?: React.ReactNode; + className?: string; + size?: 'sm' | 'md'; +} + +const variantStyles: Record = { + success: 'bg-emerald-50 text-emerald-700 border-emerald-200', + warning: 'bg-amber-50 text-amber-700 border-amber-200', + danger: 'bg-red-50 text-red-700 border-red-200', + info: 'bg-sky-50 text-sky-700 border-sky-200', + purple: 'bg-purple-50 text-purple-700 border-purple-200', + gray: 'bg-gray-50 text-gray-700 border-gray-200', +}; + +export default function Badge({ + variant = 'gray', + children, + icon, + className = '', + size = 'md', +}: BadgeProps) { + const sizeStyle = size === 'sm' ? 'px-2 py-0.5 text-[10px]' : 'px-2.5 py-1 text-xs'; + + return ( + + {icon && {icon}} + {children} + + ); +} diff --git a/frontend/admin-panel/src/components/ui/FormField.tsx b/frontend/admin-panel/src/components/ui/FormField.tsx new file mode 100644 index 0000000..61fc20f --- /dev/null +++ b/frontend/admin-panel/src/components/ui/FormField.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { HelpCircle } from 'lucide-react'; + +export interface FormFieldProps { + label?: string; + required?: boolean; + helpText?: string; + description?: string; + error?: string; + children: React.ReactNode; + className?: string; + id?: string; +} + +export default function FormField({ + label, + required, + helpText, + description, + error, + children, + className = '', + id, +}: FormFieldProps) { + return ( +
+ {label && ( +
+ + {helpText && ( +
+ +
+ {helpText} +
+
+ )} +
+ )} + {children} + {description && !error && ( +

{description}

+ )} + {error && ( +

+ + {error} +

+ )} +
+ ); +} diff --git a/frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx b/frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx new file mode 100644 index 0000000..deb93dc --- /dev/null +++ b/frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx @@ -0,0 +1,92 @@ +import React, { useEffect } from 'react'; +import { X, ZoomIn, ZoomOut, Download, ExternalLink } from 'lucide-react'; + +export interface ImagePreviewModalProps { + isOpen: boolean; + onClose: () => void; + imageUrl: string; + title?: string; +} + +export default function ImagePreviewModal({ + isOpen, + onClose, + imageUrl, + title, +}: ImagePreviewModalProps) { + const [zoom, setZoom] = React.useState(1); + + useEffect(() => { + if (isOpen) { + setZoom(1); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + } + }, [isOpen, onClose]); + + if (!isOpen || !imageUrl) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {title || 'پیش‌نمایش تصویر'} +
+
+ + + + + + +
+
+ + {/* Image Container */} +
+ {title +
+
+
+ ); +} diff --git a/frontend/admin-panel/src/components/ui/Input.tsx b/frontend/admin-panel/src/components/ui/Input.tsx new file mode 100644 index 0000000..5ddc036 --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Input.tsx @@ -0,0 +1,46 @@ +import React, { forwardRef } from 'react'; + +export interface InputProps extends React.InputHTMLAttributes { + error?: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +export const Input = forwardRef( + ({ className = '', error, leftIcon, rightIcon, disabled, ...props }, ref) => { + return ( +
+ {rightIcon && ( +
+ {rightIcon} +
+ )} + + {leftIcon && ( +
+ {leftIcon} +
+ )} + {error && ( +

+ + {error} +

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; +export default Input; diff --git a/frontend/admin-panel/src/components/ui/MediaSelector.tsx b/frontend/admin-panel/src/components/ui/MediaSelector.tsx index 7f7733b..651d067 100644 --- a/frontend/admin-panel/src/components/ui/MediaSelector.tsx +++ b/frontend/admin-panel/src/components/ui/MediaSelector.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-react'; +import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard, Eye } from 'lucide-react'; import { toast } from 'react-hot-toast'; import api, { BASE_DOMAIN } from '../../services/api'; import Spinner from './Spinner'; import Pagination from './Pagination'; import ConfirmModal from './ConfirmModal'; +import ImagePreviewModal from './ImagePreviewModal'; interface Media { id: string; @@ -31,6 +32,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa const fileInputRef = useRef(null); const pasteTimerRef = useRef>(null); const [deleteTargetId, setDeleteTargetId] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); const fetchMedia = useCallback(async () => { try { @@ -48,6 +50,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa useEffect(() => { if (!isOpen) return; let isSubscribed = true; + setIsLoading(true); api.get('/admin/media').then(res => { if (!isSubscribed) return; if (res.data?.data) { @@ -116,20 +119,35 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa const handleFileUpload = async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; - const file = e.target.files[0]; - const formData = new FormData(); - formData.append('file', file); + const files = Array.from(e.target.files); + + setIsUploading(true); + let successCount = 0; try { - setIsUploading(true); - await api.post('/admin/media/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } + const uploadPromises = files.map(async (file) => { + const formData = new FormData(); + formData.append('file', file); + return api.post('/admin/media/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); }); - toast.success('تصویر با موفقیت آپلود شد'); - fetchMedia(); + + const results = await Promise.allSettled(uploadPromises); + results.forEach(res => { + if (res.status === 'fulfilled') successCount++; + }); + + if (successCount > 0) { + toast.success(`${successCount} تصویر با موفقیت آپلود شد`); + await fetchMedia(); + } + if (successCount < files.length) { + toast.error(`خطا در آپلود ${files.length - successCount} تصویر`); + } } catch (error) { console.error('Upload failed', error); - toast.error('خطا در آپلود تصویر'); + toast.error('خطا در بارگذاری فایل‌ها'); } finally { setIsUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; @@ -141,65 +159,67 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa try { await api.delete(`/admin/media/${deleteTargetId}`); toast.success('تصویر با موفقیت حذف شد'); - setMediaList(mediaList.filter(m => m.id !== deleteTargetId)); + setMediaList(prev => prev.filter(item => item.id !== deleteTargetId)); } catch (error) { - console.error('Delete failed', error); + console.error('Failed to delete media:', error); toast.error('خطا در حذف تصویر'); } finally { setDeleteTargetId(null); } }; - const [isDragOver, setIsDragOver] = useState(false); - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - }; + const [dragOver, setDragOver] = useState(false); const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); - setIsDragOver(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const file = e.dataTransfer.files[0]; - if (file.type.startsWith('image/')) { + setDragOver(false); + if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return; + const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')); + if (files.length === 0) return; + + setIsUploading(true); + let successCount = 0; + try { + const uploadPromises = files.map(async (file) => { const formData = new FormData(); formData.append('file', file); - try { - setIsUploading(true); - await api.post('/admin/media/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' } - }); - toast.success('تصویر با موفقیت آپلود شد'); - fetchMedia(); - } catch { - toast.error('خطا در آپلود تصویر'); - } finally { - setIsUploading(false); - } + return api.post('/admin/media/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + }); + const results = await Promise.allSettled(uploadPromises); + results.forEach(res => { + if (res.status === 'fulfilled') successCount++; + }); + + if (successCount > 0) { + toast.success(`${successCount} تصویر با موفقیت آپلود شد`); + await fetchMedia(); } + } catch (error) { + console.error('Drop upload failed', error); + toast.error('خطا در آپلود تصاویر رها شده'); + } finally { + setIsUploading(false); } }; if (!isOpen) return null; return ( -
-
- {isDragOver && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > +
+ + {/* Drag overlay */} + {dragOver && (
-

تصویر را اینجا رها کنید تا آپلود شود

+

تصاویر را اینجا رها کنید تا آپلود شوند

)} @@ -210,8 +230,8 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
-

گالری رسانه

-

تصویر مورد نظر را انتخاب یا آپلود کنید

+

گالری رسانه و تصاویر

+

تصویر مورد نظر را انتخاب یا گروهی آپلود کنید

{/* Toolbar */} -
+

{mediaList.length} فایل موجود

{pasteStatus && ( - + {pasteStatus} )} @@ -239,15 +259,16 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa ref={fileInputRef} onChange={handleFileUpload} accept="image/*" + multiple className="hidden" />
@@ -271,7 +292,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
{paginatedList.map((media) => { const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`; - const isSelected = selectedUrl && imgUrl === selectedUrl; + const isSelected = selectedUrl && (imgUrl === selectedUrl || media.url === selectedUrl); return (
-
+ {/* Top Action Icons (Safe Tap Targets) */} +
+ + +
+ +
{media.filename} -
- - -
{isSelected && ( -
+
+ انتخاب شده
)}
-
-

+

+

{media.filename}

-

+

{new Date(media.createdAt).toLocaleDateString('fa-IR')}

@@ -333,7 +371,12 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa onConfirm={confirmDelete} onCancel={() => setDeleteTargetId(null)} /> + + setPreviewUrl(null)} + />
); } - diff --git a/frontend/admin-panel/src/components/ui/Select.tsx b/frontend/admin-panel/src/components/ui/Select.tsx new file mode 100644 index 0000000..de90eae --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Select.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef } from 'react'; +import { ChevronDown } from 'lucide-react'; + +export interface SelectOption { + value: string | number; + label: string; + disabled?: boolean; +} + +export interface SelectProps extends React.SelectHTMLAttributes { + options?: SelectOption[]; + error?: string; + children?: React.ReactNode; +} + +export const Select = forwardRef( + ({ className = '', error, options, children, disabled, ...props }, ref) => { + return ( +
+ +
+ +
+ {error && ( +

+ + {error} +

+ )} +
+ ); + } +); + +Select.displayName = 'Select'; +export default Select; diff --git a/frontend/admin-panel/src/components/ui/Textarea.tsx b/frontend/admin-panel/src/components/ui/Textarea.tsx new file mode 100644 index 0000000..5ad9978 --- /dev/null +++ b/frontend/admin-panel/src/components/ui/Textarea.tsx @@ -0,0 +1,33 @@ +import React, { forwardRef } from 'react'; + +export interface TextareaProps extends React.TextareaHTMLAttributes { + error?: string; +} + +export const Textarea = forwardRef( + ({ className = '', error, disabled, rows = 3, ...props }, ref) => { + return ( +
+