feat: complete settings sync, standard UI inputs, prescriptions flow, pet profile and checkout enhancements
Some checks failed
Deploy Canina / deploy (push) Failing after 51s
Some checks failed
Deploy Canina / deploy (push) Failing after 51s
This commit is contained in:
parent
b3c60a1a7d
commit
6df92f03d7
@ -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 }),
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, string>();
|
||||
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<string, any>;
|
||||
const promises: Promise<any>[] = [];
|
||||
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<string, string> = {};
|
||||
|
||||
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;
|
||||
|
||||
@ -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);
|
||||
|
||||
39
frontend/admin-panel/src/components/ui/Badge.tsx
Normal file
39
frontend/admin-panel/src/components/ui/Badge.tsx
Normal file
@ -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<BadgeVariant, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 font-bold font-vazir rounded-full border shadow-xs ${variantStyles[variant]} ${sizeStyle} ${className}`}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
58
frontend/admin-panel/src/components/ui/FormField.tsx
Normal file
58
frontend/admin-panel/src/components/ui/FormField.tsx
Normal file
@ -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 (
|
||||
<div className={`space-y-1.5 font-vazir ${className}`}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-xs font-bold text-gray-700 select-none flex items-center gap-1"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500 font-bold">*</span>}
|
||||
</label>
|
||||
{helpText && (
|
||||
<div className="group relative flex items-center">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 cursor-help hover:text-gray-600 transition-colors" />
|
||||
<div className="absolute left-0 bottom-full mb-1 hidden group-hover:block z-20 w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-lg">
|
||||
{helpText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{description && !error && (
|
||||
<p className="text-[11px] text-gray-500 font-normal leading-relaxed">{description}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 font-medium flex items-center gap-1 animate-in fade-in duration-200">
|
||||
<span>•</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx
Normal file
92
frontend/admin-panel/src/components/ui/ImagePreviewModal.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 font-vazir"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative max-w-4xl max-h-[90vh] bg-gray-900 border border-gray-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3.5 bg-gray-900/90 border-b border-gray-800 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold truncate max-w-xs">{title || 'پیشنمایش تصویر'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(z + 0.25, 2.5))}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="بزرگنمایی"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(z - 0.25, 0.5))}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="کوچکنمایی"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<a
|
||||
href={imageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="مشاهده در تب جدید"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-gray-800 rounded-lg transition-colors mr-2"
|
||||
title="بستن (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Container */}
|
||||
<div className="p-4 flex items-center justify-center overflow-auto max-h-[calc(90vh-60px)] min-h-[300px]">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title || 'Preview'}
|
||||
style={{ transform: `scale(${zoom})`, transition: 'transform 0.2s ease-out' }}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-xl shadow-lg select-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/admin-panel/src/components/ui/Input.tsx
Normal file
46
frontend/admin-panel/src/components/ui/Input.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', error, leftIcon, rightIcon, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-white border font-vazir text-sm transition-all duration-200 outline-none
|
||||
${error ? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-500/20' : 'border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20'}
|
||||
${disabled ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-200' : 'text-gray-900 placeholder:text-gray-400 hover:border-gray-300'}
|
||||
${rightIcon ? 'pr-10' : 'pr-4'}
|
||||
${leftIcon ? 'pl-10' : 'pl-4'}
|
||||
py-2.5 rounded-xl shadow-xs ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-500 font-medium font-vazir flex items-center gap-1 animate-in fade-in duration-200">
|
||||
<span>•</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
export default Input;
|
||||
@ -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<HTMLInputElement>(null);
|
||||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm transition-opacity">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in duration-200 relative transition-all ${isDragOver ? 'ring-4 ring-purple-500 scale-[1.01]' : ''}`}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs font-vazir"
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="bg-white rounded-3xl max-w-5xl w-full max-h-[90vh] flex flex-col shadow-2xl overflow-hidden border border-gray-100 relative animate-in fade-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Drag overlay */}
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-50 bg-purple-600/90 backdrop-blur-xs flex flex-col items-center justify-center text-white pointer-events-none animate-in fade-in">
|
||||
<Upload className="w-16 h-16 mb-2 animate-bounce" />
|
||||
<p className="text-xl font-bold">تصویر را اینجا رها کنید تا آپلود شود</p>
|
||||
<p className="text-xl font-bold">تصاویر را اینجا رها کنید تا آپلود شوند</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -210,8 +230,8 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">گالری رسانه</h3>
|
||||
<p className="text-xs text-gray-500 font-medium">تصویر مورد نظر را انتخاب یا آپلود کنید</p>
|
||||
<h3 className="text-lg font-bold text-gray-900">گالری رسانه و تصاویر</h3>
|
||||
<p className="text-xs text-gray-500 font-medium">تصویر مورد نظر را انتخاب یا گروهی آپلود کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors">
|
||||
@ -220,11 +240,11 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-white">
|
||||
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-white flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-bold text-gray-600">{mediaList.length} فایل موجود</p>
|
||||
{pasteStatus && (
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${pasteStatus.includes('موفق') ? 'bg-green-100 text-green-700' : pasteStatus.includes('خطا') ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${pasteStatus.includes('موفق') ? 'bg-green-100 text-green-700' : pasteStatus.includes('خطا') ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{pasteStatus}
|
||||
</span>
|
||||
)}
|
||||
@ -239,15 +259,16 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors"
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2.5 rounded-xl flex items-center gap-2 text-sm font-bold transition-all shadow-md shadow-purple-200 cursor-pointer"
|
||||
>
|
||||
{isUploading ? <Spinner size="sm" /> : <Upload className="w-4 h-4" />}
|
||||
آپلود تصویر جدید
|
||||
آپلود تصویر جدید (تکی یا گروهی)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,7 +292,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{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 (
|
||||
<div
|
||||
key={media.id}
|
||||
@ -279,38 +300,55 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
onSelect(imgUrl);
|
||||
if (!multiple) onClose();
|
||||
}}
|
||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
|
||||
className={`group relative bg-white rounded-2xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-gray-200 hover:border-purple-400'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||||
{/* Top Action Icons (Safe Tap Targets) */}
|
||||
<div className="absolute top-2 left-2 right-2 flex items-center justify-between z-10 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewUrl(imgUrl);
|
||||
}}
|
||||
className="pointer-events-auto p-1.5 rounded-lg bg-black/50 hover:bg-black/80 text-white backdrop-blur-xs transition-colors"
|
||||
title="مشاهده بزرگنمایی"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(media.id);
|
||||
}}
|
||||
className="pointer-events-auto p-1.5 rounded-lg bg-red-600/80 hover:bg-red-600 text-white backdrop-blur-xs transition-colors"
|
||||
title="حذف تصویر"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square bg-gray-50 p-2 flex items-center justify-center border-b border-gray-100 relative">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
||||
className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 left-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg">
|
||||
<div className="absolute bottom-2 right-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
انتخاب شده
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 border-t border-gray-100 bg-white" dir="ltr">
|
||||
<p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}>
|
||||
<div className="p-2.5 border-t border-gray-100 bg-white" dir="ltr">
|
||||
<p className="text-[11px] text-gray-700 truncate font-medium" title={media.filename}>
|
||||
{media.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
<p className="text-[10px] text-gray-400 mt-0.5 text-right font-vazir">
|
||||
{new Date(media.createdAt).toLocaleDateString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
@ -333,7 +371,12 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
|
||||
<ImagePreviewModal
|
||||
isOpen={!!previewUrl}
|
||||
imageUrl={previewUrl || ''}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
52
frontend/admin-panel/src/components/ui/Select.tsx
Normal file
52
frontend/admin-panel/src/components/ui/Select.tsx
Normal file
@ -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<HTMLSelectElement> {
|
||||
options?: SelectOption[];
|
||||
error?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className = '', error, options, children, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full relative font-vazir">
|
||||
<select
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-white border font-vazir text-sm transition-all duration-200 outline-none appearance-none pr-4 pl-10 py-2.5 rounded-xl shadow-xs cursor-pointer
|
||||
${error ? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-500/20' : 'border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20'}
|
||||
${disabled ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-200' : 'text-gray-900 hover:border-gray-300'}
|
||||
${className}`}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))
|
||||
: children}
|
||||
</select>
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-500 font-medium flex items-center gap-1 animate-in fade-in duration-200">
|
||||
<span>•</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
export default Select;
|
||||
33
frontend/admin-panel/src/components/ui/Textarea.tsx
Normal file
33
frontend/admin-panel/src/components/ui/Textarea.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className = '', error, disabled, rows = 3, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full relative font-vazir">
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-white border font-vazir text-sm transition-all duration-200 outline-none p-3.5 rounded-xl shadow-xs resize-y
|
||||
${error ? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-500/20' : 'border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20'}
|
||||
${disabled ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-200' : 'text-gray-900 placeholder:text-gray-400 hover:border-gray-300'}
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-500 font-medium flex items-center gap-1 animate-in fade-in duration-200">
|
||||
<span>•</span>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
export default Textarea;
|
||||
50
frontend/admin-panel/src/components/ui/ToggleSwitch.tsx
Normal file
50
frontend/admin-panel/src/components/ui/ToggleSwitch.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: ToggleSwitchProps) {
|
||||
return (
|
||||
<label
|
||||
className={`inline-flex items-center justify-between gap-4 font-vazir cursor-pointer select-none ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{(label || description) && (
|
||||
<div className="flex-1">
|
||||
{label && <span className="text-sm font-bold text-gray-800 block">{label}</span>}
|
||||
{description && <span className="text-xs text-gray-500 block mt-0.5">{description}</span>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 ${
|
||||
checked ? 'bg-purple-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? '-translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
import ImagePreviewModal from '../components/ui/ImagePreviewModal';
|
||||
|
||||
interface Media {
|
||||
id: string;
|
||||
@ -90,21 +91,35 @@ export default function MediaManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
try {
|
||||
setIsUploading(true);
|
||||
await api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
let successCount = 0;
|
||||
const promises = 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(promises);
|
||||
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 = '';
|
||||
}
|
||||
}, [fetchMedia]);
|
||||
|
||||
@ -127,19 +142,21 @@ export default function MediaManager() {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
uploadFile(file);
|
||||
break;
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
uploadFiles(files);
|
||||
}
|
||||
};
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => document.removeEventListener('paste', handlePaste);
|
||||
}, [uploadFile]);
|
||||
}, [uploadFiles]);
|
||||
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
|
||||
@ -164,16 +181,16 @@ export default function MediaManager() {
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files || e.target.files.length === 0) return;
|
||||
await uploadFile(e.target.files[0]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await uploadFiles(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
await uploadFile(file);
|
||||
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) {
|
||||
await uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
@ -242,6 +259,7 @@ export default function MediaManager() {
|
||||
ref={fileInputRef}
|
||||
onChange={handleUpload}
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
@ -544,6 +562,12 @@ export default function MediaManager() {
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
|
||||
<ImagePreviewModal
|
||||
isOpen={!!previewUrl}
|
||||
imageUrl={previewUrl || ''}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Trash2, Heart, Image as ImageIcon } from 'lucide-react';
|
||||
import { Search, Trash2, Heart, Image as ImageIcon, Eye, Clock, Activity, FileText, X, Dog, Cat, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
@ -18,12 +18,35 @@ export interface Pet {
|
||||
imageUrl?: string;
|
||||
avatarUrl?: string;
|
||||
user?: {
|
||||
firstName: ReactNode;
|
||||
lastName: ReactNode;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
};
|
||||
medicalConditions?: Array<{ condition: string }>;
|
||||
reminders?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
time: string;
|
||||
frequency: string;
|
||||
}>;
|
||||
healthLogs?: Array<{
|
||||
id: string;
|
||||
appetite: string;
|
||||
energy: string;
|
||||
digestion: string;
|
||||
note?: string;
|
||||
loggedDate: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
prescriptions?: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
adminNotes?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
ownerName?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
@ -36,6 +59,7 @@ export default function Pets() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const limit = 10;
|
||||
|
||||
const [selectedPetForView, setSelectedPetForView] = useState<Pet | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const fetchPets = useCallback(async () => {
|
||||
@ -75,74 +99,105 @@ export default function Pets() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<Heart className="w-6 h-6 text-purple-600 fill-purple-100" />
|
||||
حیوانات خانگی کاربران (Pets)
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">مشاهده و مدیریت پروفایل حیوانات خانگی ثبت شده توسط کاربران</p>
|
||||
<p className="text-gray-500 font-medium mt-1">مشاهده و پایش آنلاین سلامت، یادآورها، گزارشها و نسخههای پزشکی پتها</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||
<div className="bg-white p-4 rounded-2xl shadow-xs border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام حیوان..."
|
||||
placeholder="جستجو در نام همدم..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all text-xs font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="bg-white rounded-2xl shadow-xs border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">نام و نژاد</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">صاحب حیوان</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">مشخصات فیزیکی</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-24">عملیات</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">تصویر</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">نام و نژاد</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">صاحب حیوان</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">مشخصات فیزیکی</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">سوابق سلامت</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500 w-28">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||||
<tr><td colSpan={6} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||||
) : pets.length === 0 ? (
|
||||
<tr><td colSpan={5} className="py-12 text-center text-gray-500 font-medium">هیچ حیوانی یافت نشد</td></tr>
|
||||
<tr><td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ حیوانی یافت نشد</td></tr>
|
||||
) : (
|
||||
pets.map((pet) => (
|
||||
<tr key={pet.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-3 px-6">
|
||||
{pet.imageUrl ? (
|
||||
<img src={pet.imageUrl.startsWith('http') ? pet.imageUrl : `${BASE_DOMAIN}${pet.imageUrl}`} alt={pet.name} className="w-12 h-12 rounded-full object-cover border-2 border-purple-100" />
|
||||
<img src={pet.imageUrl.startsWith('http') ? pet.imageUrl : `${BASE_DOMAIN}${pet.imageUrl}`} alt={pet.name} className="w-12 h-12 rounded-2xl object-cover border-2 border-purple-100 shadow-xs" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center text-purple-300 border-2 border-purple-100">
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-50 flex items-center justify-center text-purple-400 border border-purple-100">
|
||||
{pet.type === 'گربه' ? <Cat className="w-6 h-6" /> : <Dog className="w-6 h-6" />}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="font-bold text-gray-900">{pet.name} <span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded-lg">{pet.type}</span></div>
|
||||
<div className="text-gray-500 text-sm">{pet.breed}</div>
|
||||
<div className="font-black text-gray-900 flex items-center gap-1.5">
|
||||
<span>{pet.name}</span>
|
||||
<span className="text-[10px] px-2 py-0.5 bg-purple-50 text-purple-700 rounded-md font-bold">{pet.type || 'سگ'}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-0.5">{pet.breed || 'نژاد نامشخص'}</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="font-bold text-gray-700">{pet.user?.firstName} {pet.user?.lastName}</div>
|
||||
<div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</div>
|
||||
<div className="font-bold text-gray-800 text-xs">{pet.user?.firstName || ''} {pet.user?.lastName || ''}</div>
|
||||
<div className="text-gray-400 text-[11px] font-mono mt-0.5">{pet.user?.mobile || pet.user?.email || '-'}</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-600 text-sm">
|
||||
{pet.age} ساله، {pet.weight} کیلوگرم<br />
|
||||
<span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span>
|
||||
<td className="py-4 px-6 text-gray-600 text-xs">
|
||||
{pet.age ? `${pet.age} ساله` : 'سن نامشخص'}، {pet.weight ? `${pet.weight} کیلوگرم` : 'وزن نامشخص'}<br />
|
||||
<span className="text-[11px] text-gray-400 font-medium">تحرک: {pet.activityLevel || 'متوسط'}</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-xs">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] text-gray-600 font-bold">
|
||||
{pet.reminders?.length || 0} یادآور • {pet.healthLogs?.length || 0} گزارش
|
||||
</span>
|
||||
{pet.prescriptions && pet.prescriptions.length > 0 && (
|
||||
<span className="text-[10px] text-purple-700 font-bold">
|
||||
{pet.prescriptions.length} نسخه پزشکی ثبت شده
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setDeleteTargetId(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPetForView(pet)}
|
||||
className="p-2 text-purple-600 hover:bg-purple-50 rounded-xl transition-colors cursor-pointer"
|
||||
title="مشاهده پرونده کامل سلامت"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTargetId(pet.id)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||||
title="حذف پت"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -158,6 +213,135 @@ export default function Pets() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pet Medical Dossier Details Modal */}
|
||||
{selectedPetForView && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs font-vazir" dir="rtl">
|
||||
<div className="bg-white rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-gray-100 flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 text-purple-700 flex items-center justify-center font-black">
|
||||
{selectedPetForView.type === 'گربه' ? <Cat className="w-5 h-5" /> : <Dog className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-black text-gray-900">پرونده پزشکی و پایش سلامت: {selectedPetForView.name}</h3>
|
||||
<p className="text-xs text-gray-400 font-mono">شناسه: {selectedPetForView.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPetForView(null)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Pet Info Banner */}
|
||||
<div className="p-4 bg-purple-50/60 rounded-2xl border border-purple-100 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 text-xs">
|
||||
<div>
|
||||
<div className="font-black text-gray-900 text-sm">{selectedPetForView.name} ({selectedPetForView.breed || selectedPetForView.type})</div>
|
||||
<div className="text-gray-600 mt-1">
|
||||
سرپرست: {selectedPetForView.user?.firstName} {selectedPetForView.user?.lastName} | همراه: <span className="font-mono">{selectedPetForView.user?.mobile || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-purple-900 font-bold bg-white px-3 py-1.5 rounded-xl border border-purple-200">
|
||||
{selectedPetForView.age} سال | {selectedPetForView.weight} کیلوگرم | تحرک {selectedPetForView.activityLevel || 'متوسط'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medical Conditions */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-purple-600" />
|
||||
سوابق و حساسیتهای پزشکی (Medical Conditions)
|
||||
</h4>
|
||||
{selectedPetForView.medicalConditions && selectedPetForView.medicalConditions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPetForView.medicalConditions.map((mc, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-gray-100 text-gray-800 text-xs font-bold rounded-xl border border-gray-200">
|
||||
{mc.condition}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">مورد پزشکی خاصی ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Reminders */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-purple-600" />
|
||||
یادآورهای فعال (Reminders)
|
||||
</h4>
|
||||
{selectedPetForView.reminders && selectedPetForView.reminders.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedPetForView.reminders.map((rem) => (
|
||||
<div key={rem.id} className="p-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center justify-between text-xs">
|
||||
<span className="font-bold text-gray-800">{rem.title}</span>
|
||||
<span className="text-purple-700 font-mono font-bold bg-white px-2 py-0.5 rounded-md border border-purple-100">
|
||||
ساعت {rem.time} ({rem.frequency})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">یادآوری فعالی ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Health Logs */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-purple-600" />
|
||||
آخرین گزارشات روزانه سلامت (Health Logs)
|
||||
</h4>
|
||||
{selectedPetForView.healthLogs && selectedPetForView.healthLogs.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedPetForView.healthLogs.map((log) => (
|
||||
<div key={log.id} className="p-3 bg-gray-50 rounded-xl border border-gray-200 text-xs space-y-1">
|
||||
<div className="flex items-center justify-between text-gray-500 font-bold">
|
||||
<span>تاریخ: {new Date(log.loggedDate || log.createdAt).toLocaleDateString('fa-IR')}</span>
|
||||
<span className="text-gray-700 font-black">اشتها: {log.appetite} | انرژی: {log.energy} | گوارش: {log.digestion}</span>
|
||||
</div>
|
||||
{log.note && <p className="text-gray-700 font-medium">{log.note}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">گزارش سلامتی ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prescriptions */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-gray-900 flex items-center gap-2 mb-2">
|
||||
<FileText className="w-4 h-4 text-purple-600" />
|
||||
نسخههای پزشکی این همدم (Prescriptions)
|
||||
</h4>
|
||||
{selectedPetForView.prescriptions && selectedPetForView.prescriptions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedPetForView.prescriptions.map((rx) => (
|
||||
<div key={rx.id} className="p-3 bg-purple-50/50 rounded-xl border border-purple-100 text-xs space-y-1">
|
||||
<div className="flex items-center justify-between font-bold">
|
||||
<span className="font-mono">نسخه #{rx.id.slice(0, 8)}</span>
|
||||
<span className="text-purple-700 font-black">{rx.status}</span>
|
||||
</div>
|
||||
{rx.notes && <p className="text-gray-600">یادداشت کاربر: {rx.notes}</p>}
|
||||
{rx.adminNotes && <p className="text-purple-900 font-bold">دستور پزشک: {rx.adminNotes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">نسخهای برای این پت ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف حیوان خانگی"
|
||||
|
||||
@ -1,20 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Eye, CheckCircle2, XCircle, Clock, Filter, X, ExternalLink } from 'lucide-react';
|
||||
import { FileText, Eye, CheckCircle2, XCircle, Clock, Filter, X, ExternalLink, Plus, Package, Phone, User, Dog, Cat } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import ImagePreviewModal from '../components/ui/ImagePreviewModal';
|
||||
import Badge from '../components/ui/Badge';
|
||||
import type { Prescription } from '../types/admin';
|
||||
|
||||
interface ProductItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
slug?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export default function PrescriptionsManager() {
|
||||
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
|
||||
const [products, setProducts] = useState<ProductItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
|
||||
// Review Modal State
|
||||
const [selectedRx, setSelectedRx] = useState<Prescription | null>(null);
|
||||
const [reviewStatus, setReviewStatus] = useState<'APPROVED' | 'REJECTED'>('APPROVED');
|
||||
const [reviewStatus, setReviewStatus] = useState<'APPROVED' | 'REJECTED' | 'PENDING'>('APPROVED');
|
||||
const [adminNotes, setAdminNotes] = useState('');
|
||||
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
|
||||
const fetchPrescriptions = async () => {
|
||||
try {
|
||||
@ -31,15 +44,21 @@ export default function PrescriptionsManager() {
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
api.get('/prescriptions')
|
||||
.then(res => {
|
||||
Promise.all([
|
||||
api.get('/prescriptions'),
|
||||
api.get('/products?limit=100').catch(() => ({ data: { data: [] } }))
|
||||
])
|
||||
.then(([rxRes, prodRes]) => {
|
||||
if (!isMounted) return;
|
||||
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
setPrescriptions(data);
|
||||
const rxData = Array.isArray(rxRes.data) ? rxRes.data : (rxRes.data?.data || []);
|
||||
setPrescriptions(rxData);
|
||||
|
||||
const prodData = Array.isArray(prodRes.data) ? prodRes.data : (prodRes.data?.data || []);
|
||||
setProducts(prodData);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch prescriptions:', err);
|
||||
toast.error('خطا در دریافت لیست نسخههای پزشکی');
|
||||
console.error('Failed to load prescriptions or products:', err);
|
||||
toast.error('خطا در بارگذاری اطلاعات');
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setIsLoading(false);
|
||||
@ -52,7 +71,21 @@ export default function PrescriptionsManager() {
|
||||
const openReviewModal = (rx: Prescription) => {
|
||||
setSelectedRx(rx);
|
||||
setReviewStatus(rx.status === 'REJECTED' ? 'REJECTED' : 'APPROVED');
|
||||
setAdminNotes(rx.adminNotes || '');
|
||||
|
||||
// Parse existing admin notes and product attachments if stored as JSON or text
|
||||
let existingNotes = rx.adminNotes || '';
|
||||
let existingProductIds: string[] = [];
|
||||
if (existingNotes.includes('__PRESCRIBED_PRODUCTS__:')) {
|
||||
const parts = existingNotes.split('__PRESCRIBED_PRODUCTS__:');
|
||||
existingNotes = parts[0].trim();
|
||||
try {
|
||||
existingProductIds = JSON.parse(parts[1]);
|
||||
} catch {
|
||||
existingProductIds = [];
|
||||
}
|
||||
}
|
||||
setAdminNotes(existingNotes);
|
||||
setSelectedProductIds(existingProductIds);
|
||||
};
|
||||
|
||||
const handleReviewSubmit = async (e: React.FormEvent) => {
|
||||
@ -61,12 +94,18 @@ export default function PrescriptionsManager() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
let finalAdminNotes = adminNotes.trim();
|
||||
if (selectedProductIds.length > 0) {
|
||||
finalAdminNotes += `\n__PRESCRIBED_PRODUCTS__:${JSON.stringify(selectedProductIds)}`;
|
||||
}
|
||||
|
||||
await api.patch(`/prescriptions/${selectedRx.id}/review`, {
|
||||
status: reviewStatus,
|
||||
adminNotes: adminNotes.trim() || undefined,
|
||||
adminNotes: finalAdminNotes || undefined,
|
||||
});
|
||||
|
||||
toast.success(`نسخه پزشکی با موفقیت ${reviewStatus === 'APPROVED' ? 'تایید' : 'رد'} شد`);
|
||||
toast.success(`نسخه پزشکی با موفقیت ${reviewStatus === 'APPROVED' ? 'تایید و پیامک ارسال' : 'بررسی'} شد`);
|
||||
setSelectedRx(null);
|
||||
fetchPrescriptions();
|
||||
} catch (err) {
|
||||
@ -89,18 +128,20 @@ export default function PrescriptionsManager() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-purple-600" />
|
||||
داشبورد بررسی نسخههای پزشکی (Prescriptions)
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">بررسی آنلاین نسخههای ارسال شده توسط کاربران و تایید دستور مصرف</p>
|
||||
<p className="text-gray-500 font-medium mt-1">
|
||||
بررسی تخصصی نسخههای ارسال شده، تجویز مکملهای مناسب کنینا و ارسال پیامک خودکار به کاربر
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Filters */}
|
||||
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-xs">
|
||||
<Filter className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{[
|
||||
{ id: 'ALL', label: 'همه' },
|
||||
@ -111,9 +152,9 @@ export default function PrescriptionsManager() {
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setStatusFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
statusFilter === f.id
|
||||
? 'bg-purple-600 text-white shadow-sm'
|
||||
? 'bg-purple-600 text-white shadow-xs'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@ -123,17 +164,17 @@ export default function PrescriptionsManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="bg-white rounded-2xl shadow-xs border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right">
|
||||
<thead className="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">شناسه / تاریخ</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">کاربر / حیوان خانگی</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">فایل نسخه</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">یادداشت کاربر</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت</th>
|
||||
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-28">عملیات</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">تاریخ / شناسه</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">مشخصات سرپرست و همدم</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">فایل نسخه</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">توضیحات کاربر</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500">وضعیت</th>
|
||||
<th className="py-4 px-6 text-xs font-black text-gray-500 w-32">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
@ -148,62 +189,70 @@ export default function PrescriptionsManager() {
|
||||
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ نسخهای با این فیلتر یافت نشد</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPrescriptions.map((rx) => (
|
||||
<tr key={rx.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-bold text-gray-800">{rx.id.slice(0, 8)}...</span>
|
||||
<span className="text-[11px] text-gray-400 mt-0.5">
|
||||
{rx.createdAt ? new Date(rx.createdAt).toLocaleDateString('fa-IR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs text-gray-600">User ID: {rx.userId.slice(0, 8)}</span>
|
||||
{rx.petId && <span className="text-xs text-purple-600 font-medium">Pet ID: {rx.petId.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<a
|
||||
href={getFileFullUrl(rx.fileUrl)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100 hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
مشاهده فایل نسخه
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-600 text-xs max-w-xs line-clamp-2">
|
||||
{rx.notes || '-'}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{rx.status === 'APPROVED' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-full border border-green-200">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> تایید شده
|
||||
</span>
|
||||
) : rx.status === 'REJECTED' ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-red-50 text-red-700 text-xs font-bold rounded-full border border-red-200">
|
||||
<XCircle className="w-3.5 h-3.5" /> رد شده
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-full border border-amber-200">
|
||||
<Clock className="w-3.5 h-3.5" /> در انتظار بررسی
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<button
|
||||
onClick={() => openReviewModal(rx)}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold transition-all shadow-sm"
|
||||
>
|
||||
بررسی / تغییر وضعیت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
filteredPrescriptions.map((rx: any) => {
|
||||
const fullFile = getFileFullUrl(rx.fileUrl);
|
||||
return (
|
||||
<tr key={rx.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-bold text-gray-800">{rx.id.slice(0, 8)}...</span>
|
||||
<span className="text-[11px] text-gray-400 mt-0.5">
|
||||
{rx.createdAt ? new Date(rx.createdAt).toLocaleDateString('fa-IR') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-gray-900">
|
||||
<User className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span>{rx.user ? `${rx.user.firstName || ''} ${rx.user.lastName || ''}`.trim() || rx.user.mobile : 'کاربر مهمان'}</span>
|
||||
</div>
|
||||
{rx.user?.mobile && (
|
||||
<span className="text-[11px] font-mono text-gray-500">{rx.user.mobile}</span>
|
||||
)}
|
||||
{rx.pet ? (
|
||||
<div className="flex items-center gap-1 text-[11px] text-purple-700 font-bold mt-1">
|
||||
{rx.pet.type === 'گربه' ? <Cat className="w-3 h-3" /> : <Dog className="w-3 h-3" />}
|
||||
<span>{rx.pet.name} ({rx.pet.breed || rx.pet.type})</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-400">بدون نام پت</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewImageUrl(fullFile)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-50 text-purple-700 text-xs font-bold rounded-xl border border-purple-100 hover:bg-purple-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
مشاهده تصویر نسخه
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-600 text-xs max-w-xs line-clamp-2">
|
||||
{rx.notes || '-'}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{rx.status === 'APPROVED' ? (
|
||||
<Badge variant="success" icon={<CheckCircle2 className="w-3.5 h-3.5" />}>تایید شده</Badge>
|
||||
) : rx.status === 'REJECTED' ? (
|
||||
<Badge variant="danger" icon={<XCircle className="w-3.5 h-3.5" />}>رد شده</Badge>
|
||||
) : (
|
||||
<Badge variant="warning" icon={<Clock className="w-3.5 h-3.5" />}>در انتظار بررسی</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<button
|
||||
onClick={() => openReviewModal(rx)}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-bold transition-all shadow-xs cursor-pointer"
|
||||
>
|
||||
بررسی و تجویز
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -212,75 +261,122 @@ export default function PrescriptionsManager() {
|
||||
|
||||
{/* Review Modal */}
|
||||
{selectedRx && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in duration-200">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
بررسی نسخه پزشکی ({selectedRx.id.slice(0, 8)})
|
||||
</h3>
|
||||
<button onClick={() => setSelectedRx(null)} className="text-gray-400 hover:text-red-500 transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs font-vazir" dir="rtl">
|
||||
<div className="bg-white rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-gray-100 flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">بررسی و ثبت نظر تخصصی نسخه</h3>
|
||||
<p className="text-xs text-gray-500">کد نسخه: {selectedRx.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedRx(null)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleReviewSubmit} className="p-6 space-y-4">
|
||||
<div className="p-4 bg-purple-50/50 rounded-xl border border-purple-100 space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold text-gray-600">فایل نسخه:</span>
|
||||
<a
|
||||
href={getFileFullUrl(selectedRx.fileUrl)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-purple-600 font-bold underline flex items-center gap-1"
|
||||
>
|
||||
باز کردن در پنجره جدید <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<form onSubmit={handleReviewSubmit} className="p-6 space-y-5">
|
||||
{/* Pet & User Details Card */}
|
||||
<div className="p-4 bg-gray-50 rounded-2xl border border-gray-200 text-xs space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-gray-700">سرپرست: {(selectedRx as any).user ? `${(selectedRx as any).user.firstName || ''} ${(selectedRx as any).user.lastName || ''}`.trim() || (selectedRx as any).user.mobile : 'کاربر مهمان'}</span>
|
||||
<span className="font-mono text-gray-600">{(selectedRx as any).user?.mobile || '-'}</span>
|
||||
</div>
|
||||
{(selectedRx as any).pet && (
|
||||
<div className="pt-2 border-t border-gray-200 flex items-center justify-between text-purple-900 font-bold">
|
||||
<span>همدم: {(selectedRx as any).pet.name} ({(selectedRx as any).pet.type} - {(selectedRx as any).pet.breed})</span>
|
||||
<span>وزن: {(selectedRx as any).pet.weight} kg | سن: {(selectedRx as any).pet.age} سال</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedRx.notes && (
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">یادداشت کاربر: </span>
|
||||
<span className="text-gray-800">{selectedRx.notes}</span>
|
||||
<div className="pt-2 border-t border-gray-200 text-gray-700">
|
||||
<strong className="block mb-0.5 text-gray-900">یادداشت کاربر:</strong>
|
||||
{selectedRx.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">تغییر وضعیت بررسی *</label>
|
||||
<label className="block text-xs font-black text-gray-700 mb-2">تغییر وضعیت نسخه *</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewStatus('APPROVED')}
|
||||
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all ${
|
||||
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all cursor-pointer ${
|
||||
reviewStatus === 'APPROVED'
|
||||
? 'border-green-500 bg-green-50 text-green-700 shadow-sm'
|
||||
? 'border-green-500 bg-green-50 text-green-700 shadow-xs font-black'
|
||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" /> تایید نسخه (APPROVED)
|
||||
<CheckCircle2 className="w-4 h-4" /> تایید و ثبت تجویز (APPROVED)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewStatus('REJECTED')}
|
||||
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all ${
|
||||
className={`py-3 px-4 rounded-xl border font-bold text-xs flex items-center justify-center gap-2 transition-all cursor-pointer ${
|
||||
reviewStatus === 'REJECTED'
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm'
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-xs font-black'
|
||||
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> عدم تایید / رد (REJECTED)
|
||||
<XCircle className="w-4 h-4" /> عدم تایید / رد نسخه (REJECTED)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prescribed Products Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">یادداشت کارشناس ادمین (adminNotes)</label>
|
||||
<label className="block text-xs font-black text-gray-700 mb-1 flex items-center justify-between">
|
||||
<span>محصولات و مکملهای تجویزی پزشک (جهت خرید آسان کاربر)</span>
|
||||
<span className="text-[10px] text-gray-400 font-normal">{selectedProductIds.length} محصول انتخاب شده</span>
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto border border-gray-200 rounded-xl p-2 space-y-1 bg-white">
|
||||
{products.map(p => {
|
||||
const isSelected = selectedProductIds.includes(p.id);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setSelectedProductIds(prev =>
|
||||
isSelected ? prev.filter(id => id !== p.id) : [...prev, p.id]
|
||||
);
|
||||
}}
|
||||
className={`p-2 rounded-lg text-xs font-bold flex items-center justify-between cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-purple-100 text-purple-900' : 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Package className="w-3.5 h-3.5 shrink-0 text-purple-600" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-gray-500 shrink-0">
|
||||
{p.price ? `${p.price.toLocaleString('fa-IR')} تومان` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Doctor / Admin Clinical Note */}
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-700 mb-1">
|
||||
توضیحات و دستور مصرف داروها برای بیمار (پیام به کاربر)
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
rows={3}
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder="مثال: نسخه تایید شد. دوز مصرفی مطابق دستور پزشک است."
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
|
||||
placeholder="مثال: نسخه تایید شد. مکمل کانین ایمون روزانه ۱ قرص به همراه وعده غذایی مصرف شود."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -288,23 +384,31 @@ export default function PrescriptionsManager() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRx(null)}
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 text-xs font-bold hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
|
||||
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white text-xs font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{isSubmitting && <Spinner size="sm" />}
|
||||
ثبت وضعیت بررسی
|
||||
ثبت نهایی و ارسال پیامک
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Lightbox for Prescription Image */}
|
||||
<ImagePreviewModal
|
||||
isOpen={!!previewImageUrl}
|
||||
imageUrl={previewImageUrl || ''}
|
||||
onClose={() => setPreviewImageUrl(null)}
|
||||
title="تصویر نسخه پزشکی بیمار"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Languages, Save, Search, Loader2, Layout, Sliders, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
|
||||
import { Languages, Save, Search, Loader2, Layout, Sliders, Link as LinkIcon, FileText, Check, Upload, HelpCircle, Eye } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import ToggleSwitch from '../components/ui/ToggleSwitch';
|
||||
import ImagePreviewModal from '../components/ui/ImagePreviewModal';
|
||||
|
||||
const PAGE_TABS = [
|
||||
{ id: 'all', label: 'همه بخشها', icon: Layout },
|
||||
@ -205,7 +207,41 @@ export default function UITexts() {
|
||||
}
|
||||
};
|
||||
|
||||
const isImageKey = (key: string) => key.includes('image') || key.includes('url') || key.includes('photo');
|
||||
const isBoolKey = (key: string, val: string) => {
|
||||
return val === 'true' || val === 'false' || key.includes('mode') || key.includes('disable') || key.includes('hide') || key.includes('enable');
|
||||
};
|
||||
|
||||
const [previewModalUrl, setPreviewModalUrl] = useState<string | null>(null);
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
const dirtyKeys = Object.keys(edits);
|
||||
if (dirtyKeys.length === 0) {
|
||||
toast.info('هیچ تغییری برای ذخیره وجود ندارد');
|
||||
return;
|
||||
}
|
||||
setIsSavingAll(true);
|
||||
try {
|
||||
await api.put('/settings/ui-texts', edits);
|
||||
setTexts(prev => ({ ...prev, ...edits }));
|
||||
setEdits({});
|
||||
toast.success(`${dirtyKeys.length} تغییر با موفقیت ذخیره شد`);
|
||||
} catch (e) {
|
||||
console.error('Failed to save all UI texts:', e);
|
||||
// Fallback sequential
|
||||
try {
|
||||
const promises = dirtyKeys.map(k => api.put(`/settings/ui-texts/${encodeURIComponent(k)}`, { value: edits[k] }));
|
||||
await Promise.all(promises);
|
||||
setTexts(prev => ({ ...prev, ...edits }));
|
||||
setEdits({});
|
||||
toast.success(`${dirtyKeys.length} تغییر با موفقیت ذخیره شد`);
|
||||
} catch (err) {
|
||||
toast.error('خطا در ذخیرهسازی گروهی تغییرات');
|
||||
}
|
||||
} finally {
|
||||
setIsSavingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -215,6 +251,8 @@ export default function UITexts() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = Object.keys(edits).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||||
{/* Header */}
|
||||
@ -228,6 +266,20 @@ export default function UITexts() {
|
||||
ویرایش زنده متون، تصاویر بنر، لینک دکمهها و عناوین تمام صفحات سایت کنینا
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isSavingAll || !hasUnsavedChanges}
|
||||
className={`px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-sm ${
|
||||
hasUnsavedChanges
|
||||
? 'bg-purple-600 hover:bg-purple-700 text-white shadow-purple-200 cursor-pointer animate-pulse'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>ذخیره تمامی تغییرات {hasUnsavedChanges ? `(${Object.keys(edits).length})` : ''}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input for Image Upload */}
|
||||
@ -325,36 +377,58 @@ export default function UITexts() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{val.length > 60 && !isImg ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{isBoolKey(key, val) ? (
|
||||
<div className="flex-1 bg-white border border-gray-200 rounded-xl px-4 py-2.5 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-700">
|
||||
وضعیت: {val === 'true' ? <strong className="text-green-600">فعال (روشن)</strong> : <strong className="text-gray-400">غیرفعال (خاموش)</strong>}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={val === 'true'}
|
||||
onChange={(checked) => setEdits({ ...edits, [key]: String(checked) })}
|
||||
/>
|
||||
</div>
|
||||
) : val.length > 60 && !isImg ? (
|
||||
<textarea
|
||||
value={val}
|
||||
onChange={e => setEdits({ ...edits, [key]: e.target.value })}
|
||||
className="w-full bg-white border border-gray-200 rounded-xl p-3 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-canina-blue/20 h-20"
|
||||
className="w-full bg-white border border-gray-200 rounded-xl p-3 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500/20 h-20"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={e => setEdits({ ...edits, [key]: e.target.value })}
|
||||
className="w-full bg-white border border-gray-200 rounded-xl px-3 py-2.5 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
className="w-full bg-white border border-gray-200 rounded-xl px-3 py-2.5 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500/20"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isImg && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onClick={() => {
|
||||
currentKeyForUpload.current = key;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className="px-3 py-2.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-xl text-xs font-black hover:bg-purple-100 transition-all shrink-0 flex items-center gap-1"
|
||||
title="آپلود تصویر"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>{isUploading ? '...' : 'عکس'}</span>
|
||||
</button>
|
||||
<>
|
||||
{val && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewModalUrl(val.startsWith('http') ? val : `${BASE_DOMAIN}${val}`)}
|
||||
className="p-2.5 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-xl text-xs font-bold transition-all shrink-0 flex items-center justify-center"
|
||||
title="پیشنمایش تصویر"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploading}
|
||||
onClick={() => {
|
||||
currentKeyForUpload.current = key;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className="px-3 py-2.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-xl text-xs font-black hover:bg-purple-100 transition-all shrink-0 flex items-center gap-1"
|
||||
title="آپلود تصویر"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>{isUploading ? '...' : 'عکس'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
@ -378,6 +452,12 @@ export default function UITexts() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ImagePreviewModal
|
||||
isOpen={!!previewModalUrl}
|
||||
imageUrl={previewModalUrl || ''}
|
||||
onClose={() => setPreviewModalUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ import api from "../lib/services/api";
|
||||
export default function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const { items, getTotal, getSubtotal, isSubscribed, charityDonation, setCharityDonation, addOrder, clearCart } = useCartStore();
|
||||
const { getActivePet, updatePet } = usePetStore();
|
||||
const { pets, activePetId, getActivePet, updatePet } = usePetStore();
|
||||
const { profile, isLoggedIn } = useUserStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useRoundUp, setUseRoundUp] = useState(false);
|
||||
@ -41,6 +41,7 @@ export default function CheckoutPage() {
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
|
||||
const [selectedPetId, setSelectedPetId] = useState<string | null>(activePetId || (pets[0]?.id ?? null));
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>('');
|
||||
|
||||
// Custom manual address state if guest or not using stored addresses
|
||||
@ -130,7 +131,7 @@ export default function CheckoutPage() {
|
||||
charityDonation: charityDonation,
|
||||
paymentMethod: paymentMethod,
|
||||
isRefill: isSubscribed,
|
||||
petId: activePet?.id,
|
||||
petId: selectedPetId || undefined,
|
||||
shippingAddress: shippingAddressStr
|
||||
});
|
||||
|
||||
@ -442,6 +443,47 @@ export default function CheckoutPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pet Selection */}
|
||||
{pets.length > 0 && (
|
||||
<section className="bg-white rounded-[2rem] sm:rounded-[3rem] border border-medical-gray-200 p-5 sm:p-8 overflow-hidden relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-2xl flex items-center justify-center font-black">🐾</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-medical-gray-900 italic">انتخاب همدم (حیوان خانگی)</h2>
|
||||
<p className="text-xs text-medical-gray-500">برای ثبت سابقه خرید در پرونده سلامت پت، همدم موردنظر را انتخاب کنید:</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPetId(null)}
|
||||
className={cn(
|
||||
"p-3 rounded-2xl border-2 text-xs font-black transition-all flex items-center justify-center gap-2 cursor-pointer",
|
||||
selectedPetId === null ? "border-canina-blue bg-canina-blue/10 text-canina-blue shadow-xs" : "border-medical-gray-100 text-medical-gray-500 hover:bg-medical-gray-50"
|
||||
)}
|
||||
>
|
||||
<span>هیچکدام (خرید عمومی)</span>
|
||||
</button>
|
||||
{pets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPetId(p.id)}
|
||||
className={cn(
|
||||
"p-3 rounded-2xl border-2 text-xs font-black transition-all flex items-center justify-center gap-2 cursor-pointer",
|
||||
selectedPetId === p.id ? "border-canina-blue bg-canina-blue/10 text-canina-blue shadow-xs" : "border-medical-gray-100 text-medical-gray-600 hover:bg-medical-gray-50"
|
||||
)}
|
||||
>
|
||||
<span>🐾 {p.name} ({p.breed || p.type})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 2: Payment */}
|
||||
<section className="bg-white rounded-[2rem] sm:rounded-[3rem] border border-medical-gray-200 p-5 sm:p-10 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-2.5 sm:w-3 h-full bg-medical-gray-900 opacity-20" />
|
||||
|
||||
@ -84,6 +84,32 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne
|
||||
}
|
||||
};
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchEndX = useRef<number | null>(null);
|
||||
const minSwipeDistance = 40;
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
touchEndX.current = null;
|
||||
touchStartX.current = e.targetTouches[0].clientX;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
touchEndX.current = e.targetTouches[0].clientX;
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStartX.current || !touchEndX.current) return;
|
||||
const distance = touchStartX.current - touchEndX.current;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
const isRightSwipe = distance < -minSwipeDistance;
|
||||
|
||||
if (isLeftSwipe) {
|
||||
handleNextSlide();
|
||||
} else if (isRightSwipe) {
|
||||
handlePrevSlide();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-white py-16 lg:py-28 border-b border-medical-gray-100">
|
||||
{/* Background patterns */}
|
||||
@ -160,7 +186,12 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="relative z-10"
|
||||
>
|
||||
<div className="aspect-square bg-gradient-to-br from-slate-200 to-slate-300 rounded-[2.5rem] overflow-hidden border-4 border-white shadow-2xl relative group">
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-slate-200 to-slate-300 rounded-[2.5rem] overflow-hidden border-4 border-white shadow-2xl relative group touch-pan-y"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.img
|
||||
key={activeImageUrl}
|
||||
|
||||
@ -30,11 +30,12 @@ import { usePetStore, PetProfile as GlobalPetProfile, Reminder, HealthLog } from
|
||||
import { useCartStore } from "../lib/store/cartStore";
|
||||
import SafeImage from "./SafeImage";
|
||||
import SmartAdvisor from "./SmartAdvisor";
|
||||
import OrderDetailsModal from "./OrderDetailsModal";
|
||||
import { toast } from "sonner";
|
||||
import { toPersian, cn } from "../lib/utils";
|
||||
import api from "../lib/services/api";
|
||||
import api, { BASE_DOMAIN } from "../lib/services/api";
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function PetProfile({ initialView, advisorNeed, embedded = false }: {
|
||||
initialView?: "index" | "detail" | "add" | "edit",
|
||||
@ -42,16 +43,26 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { pets, addPet, removePet, setActivePet, getActivePet, updatePet, addReminder, toggleReminder, addHealthLog } = usePetStore();
|
||||
const { orders } = useCartStore();
|
||||
const activePet = getActivePet();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [prescriptions, setPrescriptions] = useState<any[]>([]);
|
||||
const [selectedInvoiceOrder, setSelectedInvoiceOrder] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
productService.getProducts({ limit: 999 })
|
||||
.then(res => setProducts(res.data))
|
||||
.catch(err => console.error("Error fetching products in PetProfile:", err));
|
||||
|
||||
api.get("/prescriptions")
|
||||
.then(res => {
|
||||
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
|
||||
setPrescriptions(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -67,7 +78,16 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
}
|
||||
}, [initialView]);
|
||||
|
||||
const [activePetTab, setActivePetTab] = useState<"health" | "orders">("health");
|
||||
const [activePetTab, setActivePetTab] = useState<"health" | "orders" | "prescriptions">("health");
|
||||
|
||||
// URL query param retention for tab
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam && ['health', 'orders', 'prescriptions'].includes(tabParam)) {
|
||||
setActivePetTab(tabParam as "health" | "orders" | "prescriptions");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [petToDelete, setPetToDelete] = useState<GlobalPetProfile | null>(null);
|
||||
|
||||
// Modal states
|
||||
@ -95,6 +115,11 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
return orders.filter(o => o.petId === activePet.id);
|
||||
}, [activePet, orders]);
|
||||
|
||||
const petPrescriptions = useMemo(() => {
|
||||
if (!activePet) return [];
|
||||
return prescriptions.filter(rx => rx.petId === activePet.id || rx.pet?.id === activePet.id || rx.petName === activePet.name);
|
||||
}, [activePet, prescriptions]);
|
||||
|
||||
const petCharityTotal = useMemo(() => {
|
||||
return petOrders.reduce((sum, o) => sum + (Number(o.charityDonation) || 0), 0);
|
||||
}, [petOrders]);
|
||||
@ -139,18 +164,39 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 2: Biological factors
|
||||
if (activePet.age > 7) {
|
||||
const heart = products.find(p => p.id === "herz-vital" || p.slug?.includes("herz"));
|
||||
if (heart && !picks.some(x => x.product.id === heart.id)) {
|
||||
picks.push({ product: heart, reason: "محافظت از قلب پتهای مسن" });
|
||||
}
|
||||
// Priority 2: Biological & Life-stage factors
|
||||
if (activePet.age >= 7) {
|
||||
const seniorSupplements = products.filter(p =>
|
||||
(p.id === "herz-vital" || p.slug?.includes("herz") || p.name.includes("قلب") || p.benefits?.includes("مسن")) &&
|
||||
(p.suitableFor === activePet.type || p.suitableFor === "هر دو")
|
||||
);
|
||||
seniorSupplements.forEach(p => {
|
||||
if (!picks.some(x => x.product.id === p.id)) {
|
||||
picks.push({ product: p, reason: "محافظت از قلب و مفاصل در سنین بالا" });
|
||||
}
|
||||
});
|
||||
} else if (activePet.age > 0 && activePet.age <= 1) {
|
||||
const growthSupplements = products.filter(p =>
|
||||
(p.name.includes("رشد") || p.benefits?.includes("تغذیه توله") || p.benefits?.includes("کلسیم")) &&
|
||||
(p.suitableFor === activePet.type || p.suitableFor === "هر دو")
|
||||
);
|
||||
growthSupplements.forEach(p => {
|
||||
if (!picks.some(x => x.product.id === p.id)) {
|
||||
picks.push({ product: p, reason: "تغذیه متوازن و تقویت اسکلت در دوره رشد" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fill defaults if needed
|
||||
if (picks.length < 4) {
|
||||
const defaults = products.filter(p => !picks.some(x => x.product.id === p.id) && (p.suitableFor === activePet.type || p.suitableFor === "هر دو"));
|
||||
defaults.slice(0, 4 - picks.length).forEach(p => picks.push({ product: p }));
|
||||
if (activePet.activityLevel === "زیاد") {
|
||||
const performanceSupplements = products.filter(p =>
|
||||
(p.name.includes("انرژی") || p.benefits?.includes("عضله") || p.benefits?.includes("مفصل")) &&
|
||||
(p.suitableFor === activePet.type || p.suitableFor === "هر دو")
|
||||
);
|
||||
performanceSupplements.forEach(p => {
|
||||
if (!picks.some(x => x.product.id === p.id)) {
|
||||
picks.push({ product: p, reason: "تقویت مفاصل و ریکاوری برای سطح فعالیت بالا" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return picks;
|
||||
@ -609,6 +655,19 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
<span className="font-black text-sm italic">{condition}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-[10px] font-black text-medical-gray-400 uppercase tracking-widest mb-2 pr-2">
|
||||
توضیحات و سوابق تکمیلی (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.extraNotes || formData.notes || ""}
|
||||
onChange={e => setFormData({ ...formData, extraNotes: e.target.value, notes: e.target.value })}
|
||||
placeholder="سابقه جراحی، حساسیت دارویی، رژیم غذایی یا هر نکته دیگری که پزشکان کنینا باید بدانند..."
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-xs font-medium text-medical-gray-900 focus:ring-2 focus:ring-canina-blue/20 outline-none resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -876,12 +935,13 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
<div className="flex border-b border-medical-gray-200 gap-8 px-4">
|
||||
{[
|
||||
{ id: "health", label: "داشبورد سلامت و یادآوریها", icon: <Heart className="w-4 h-4" /> },
|
||||
{ id: "orders", label: "سوابق خرید و تاریخچه تخصصی", icon: <Package className="w-4 h-4" /> },
|
||||
{ id: "orders", label: "سوابق خرید و فاکتورها", icon: <Package className="w-4 h-4" /> },
|
||||
{ id: "prescriptions", label: "نسخههای پزشکی این همدم", icon: <Stethoscope className="w-4 h-4" /> },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActivePetTab(tab.id as "health" | "orders")}
|
||||
className={`pb-4 flex items-center gap-2 text-sm font-black transition-all relative ${activePetTab === tab.id ? "text-canina-blue" : "text-medical-gray-400 hover:text-medical-gray-600"}`}
|
||||
onClick={() => setActivePetTab(tab.id as "health" | "orders" | "prescriptions")}
|
||||
className={`pb-4 flex items-center gap-2 text-sm font-black transition-all relative cursor-pointer ${activePetTab === tab.id ? "text-canina-blue" : "text-medical-gray-400 hover:text-medical-gray-600"}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
@ -899,40 +959,48 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-canina-blue shadow-lg border border-medical-gray-100">
|
||||
<Star className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-black text-medical-gray-900 italic">توصیههای درمانی اختصاصی</h3>
|
||||
<h3 className="text-3xl font-black text-medical-gray-900 italic">توصیههای درمانی هوشمند</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-wider">کاتالوگ ۲۰۲۴ آلمان</p>
|
||||
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-wider">کاتالوگ رسمی کنینا آلمان</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{recommendedProducts.map(({ product: p, reason }) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
onClick={() => router.push(`/shop/${p.slug || p.id}`)}
|
||||
className="bg-white rounded-[2.5rem] p-6 border border-medical-gray-200 hover:shadow-2xl transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden"
|
||||
>
|
||||
{reason && (
|
||||
<div className="absolute top-0 left-0 right-0 bg-canina-blue/10 py-2 px-4 text-[9px] font-black text-canina-blue text-center border-b border-canina-blue/10">
|
||||
{reason}
|
||||
{recommendedProducts.length === 0 ? (
|
||||
<div className="p-8 bg-medical-gray-50/70 border border-dashed border-medical-gray-200 rounded-3xl text-center">
|
||||
<p className="text-xs font-bold text-medical-gray-500">
|
||||
بر اساس آخرین وضعیت پایش شده، نیاز درمانی فعالی برای {activePet.name} ثبت نشده است. جهت حفظ سلامتی عمومی میتوانید از کاتالوگ محصولات دیدن فرمایید.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{recommendedProducts.map(({ product: p, reason }) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
onClick={() => router.push(`/shop/${p.slug || p.id}`)}
|
||||
className="bg-white rounded-[2.5rem] p-6 border border-medical-gray-200 hover:shadow-2xl transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden"
|
||||
>
|
||||
{reason && (
|
||||
<div className="absolute top-0 left-0 right-0 bg-canina-blue/10 py-2 px-4 text-[9px] font-black text-canina-blue text-center border-b border-canina-blue/10">
|
||||
{reason}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("aspect-square bg-medical-gray-50 rounded-[1.5rem] p-6 mb-6 flex items-center justify-center overflow-hidden", reason ? "mt-8" : "")}>
|
||||
<SafeImage src={p.image} alt={p.name} className="w-full h-full group-hover:scale-110 transition-transform duration-500" imgClassName="object-contain" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("aspect-square bg-medical-gray-50 rounded-[1.5rem] p-6 mb-6 flex items-center justify-center overflow-hidden", reason ? "mt-8" : "")}>
|
||||
<SafeImage src={p.image} alt={p.name} className="w-full h-full group-hover:scale-110 transition-transform duration-500" imgClassName="object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-2 italic">{p.name}</h4>
|
||||
<p className="text-xs text-medical-gray-400 line-clamp-2 leading-relaxed mb-6 font-medium">{p.benefits}</p>
|
||||
<button className="w-full py-3.5 bg-medical-gray-900 text-white rounded-xl text-[10px] font-black uppercase tracking-widest group-hover:bg-canina-blue transition-colors flex items-center justify-center gap-2">
|
||||
بررسی تخصصی
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-2 italic">{p.name}</h4>
|
||||
<p className="text-xs text-medical-gray-400 line-clamp-2 leading-relaxed mb-6 font-medium">{p.benefits}</p>
|
||||
<button className="w-full py-3.5 bg-medical-gray-900 text-white rounded-xl text-[10px] font-black uppercase tracking-widest group-hover:bg-canina-blue transition-colors flex items-center justify-center gap-2">
|
||||
بررسی تخصصی
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-10">
|
||||
@ -979,9 +1047,6 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
<p className="text-[10px] text-medical-gray-400 font-bold">{toPersian(reminder.time)} - {reminder.frequency}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-black italic", isCompleted ? "text-green-600" : "text-medical-gray-400")}>
|
||||
{isCompleted ? "انجام شد" : "در انتظار"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@ -992,38 +1057,38 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
onClick={() => setIsAddingReminder(true)}
|
||||
className="w-full mt-8 py-5 border-2 border-dashed border-medical-gray-200 rounded-2xl text-medical-gray-400 font-black hover:border-canina-blue hover:text-canina-blue transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
افزودن یادآور جدید
|
||||
<Plus className="w-5 h-5" />
|
||||
تعریف یادآور درمانی جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Health Logs */}
|
||||
<div className="bg-white rounded-[3rem] border border-medical-gray-200 p-10 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-10">
|
||||
<ClipboardList className="w-8 h-8 text-canina-blue" />
|
||||
<h3 className="text-2xl font-black text-medical-gray-900 italic">تایملاین سلامت</h3>
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-8 h-8 text-canina-blue" />
|
||||
<h3 className="text-2xl font-black text-medical-gray-900 italic">گزارشات سلامت</h3>
|
||||
</div>
|
||||
<ClipboardList className="w-6 h-6 text-canina-blue" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 max-h-[400px] overflow-y-auto pr-2 scrollbar-hide">
|
||||
<div className="space-y-4">
|
||||
{(activePet?.logs || []).length === 0 ? (
|
||||
<div className="text-center py-10 bg-medical-gray-50 rounded-2xl border border-dashed border-medical-gray-200">
|
||||
<p className="text-sm font-bold text-medical-gray-400 italic">هنوز گزارشی ثبت نشده است</p>
|
||||
</div>
|
||||
) : (
|
||||
(activePet?.logs || []).map((log) => (
|
||||
<div key={log.id} className="bg-medical-gray-50 p-6 rounded-[2.5rem] border border-medical-gray-100 relative group">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">
|
||||
{toPersian(new Date(log.date).toLocaleDateString("fa-IR"))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="px-2 py-0.5 bg-canina-blue/10 text-canina-blue rounded-full text-[8px] font-black">{log.appetite}</div>
|
||||
<div className="px-2 py-0.5 bg-green-500/10 text-green-500 rounded-full text-[8px] font-black">{log.energy}</div>
|
||||
(activePet?.logs || []).slice(0, 3).map((log) => (
|
||||
<div key={log.id} className="p-5 bg-medical-gray-50 rounded-2xl border border-medical-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black bg-white px-2 py-0.5 rounded-md border border-medical-gray-200 text-medical-gray-600">
|
||||
{toPersian(new Date(log.date).toLocaleDateString("fa-IR"))}
|
||||
</span>
|
||||
<span className="text-xs font-black text-medical-gray-900">اشتها: {log.appetite} | انرژی: {log.energy}</span>
|
||||
</div>
|
||||
{log.note && <p className="text-[11px] text-medical-gray-500 line-clamp-1">{log.note}</p>}
|
||||
</div>
|
||||
<p className="text-sm text-medical-gray-600 font-medium leading-relaxed italic">
|
||||
{log.note || "وضعیتی برای این روز ثبت نشده است."}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@ -1038,7 +1103,7 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
) : activePetTab === "orders" ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@ -1051,7 +1116,7 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-medical-gray-900 italic">تاریخچه خرید {activePet.name}</h3>
|
||||
<p className="text-xs font-bold text-medical-gray-400 mt-1">لیست محصولات خریداری شده ویژه این پرونده سلامت</p>
|
||||
<p className="text-xs font-bold text-medical-gray-400 mt-1">برای مشاهده جزئیات کامل فاکتور و پیگیری، روی هر سفارش کلیک کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@ -1066,50 +1131,124 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
<Package className="w-12 h-12" />
|
||||
</div>
|
||||
<p className="text-medical-gray-400 font-black text-xl italic">هنوز سفارشی برای {activePet.name} ثبت نکردهاید.</p>
|
||||
<button onClick={() => router.push('/shop')} className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:scale-105 transition-all">شروع اولین خرید</button>
|
||||
<button onClick={() => router.push('/shop')} className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:scale-105 transition-all cursor-pointer">شروع اولین خرید</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{petOrders.map(order =>
|
||||
order.items.map(item => (
|
||||
<motion.div
|
||||
key={`${order.id}-${item.product.id}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
whileHover={{ x: -10, backgroundColor: "rgba(249, 250, 251, 1)" }}
|
||||
className="p-6 bg-white rounded-[2.5rem] border border-medical-gray-200 flex items-center justify-between gap-6 transition-all cursor-pointer group shadow-sm hover:shadow-xl"
|
||||
onClick={() => router.push(`/shop/${item.product.slug || item.product.id}`)}
|
||||
>
|
||||
{/* Info on Left (Text side) */}
|
||||
<div className="flex-1 text-right">
|
||||
<h4 className="text-xl font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-2 italic">{item.product.name}</h4>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 items-center">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-medical-gray-500">
|
||||
<Calendar className="w-4 h-4 text-canina-blue/40" />
|
||||
{toPersian(new Date(order.date).toLocaleDateString("fa-IR", { year: 'numeric', month: 'long', day: 'numeric' }))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-green-50 text-green-600 rounded-full text-[10px] font-black italic">
|
||||
<Check className="w-3 h-3" />
|
||||
تایید شده و تحویل شده
|
||||
</div>
|
||||
<div className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest bg-medical-gray-50 px-3 py-1 rounded-lg">
|
||||
فاکتور: {toPersian(order.id)}
|
||||
</div>
|
||||
{petOrders.map(order => (
|
||||
<motion.div
|
||||
key={order.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
whileHover={{ x: -6 }}
|
||||
onClick={() => setSelectedInvoiceOrder(order)}
|
||||
className="p-6 bg-white rounded-[2.5rem] border border-medical-gray-200 hover:border-canina-blue/50 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-6 transition-all cursor-pointer group shadow-sm hover:shadow-xl"
|
||||
>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-black text-medical-gray-900">سفارش #{toPersian(order.id.slice(-6))}</span>
|
||||
<span className="text-[10px] font-black px-2.5 py-0.5 rounded-full bg-green-50 text-green-600">
|
||||
{toPersian(order.items.length)} قلم کالا
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 items-center text-xs text-medical-gray-500 font-bold">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4 text-canina-blue/40" />
|
||||
{toPersian(new Date(order.date).toLocaleDateString("fa-IR", { year: 'numeric', month: 'long', day: 'numeric' }))}
|
||||
</div>
|
||||
<div className="text-canina-blue font-black">
|
||||
مبلغ کل: {toPersian(order.total.toLocaleString())} تومان
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image on Right (Icon side) */}
|
||||
<div className="w-20 h-20 shrink-0 bg-medical-gray-50 rounded-[1.5rem] p-3 flex items-center justify-center border border-medical-gray-100 group-hover:bg-white transition-colors relative overflow-hidden">
|
||||
<SafeImage
|
||||
src={item.product.image}
|
||||
alt={item.product.name}
|
||||
className="w-full h-full"
|
||||
imgClassName="object-contain"
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{order.items.slice(0, 3).map((item, idx) => (
|
||||
<div key={idx} className="w-14 h-14 bg-medical-gray-50 rounded-2xl p-2 flex items-center justify-center border border-medical-gray-100 overflow-hidden">
|
||||
<SafeImage src={item.product.image} alt={item.product.name} className="w-full h-full" imgClassName="object-contain" />
|
||||
</div>
|
||||
))}
|
||||
<div className="px-4 py-2 bg-canina-blue/10 text-canina-blue text-xs font-black rounded-xl group-hover:bg-canina-blue group-hover:text-white transition-colors">
|
||||
مشاهده فاکتور
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
/* Prescriptions Tab */
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-[4rem] border border-medical-gray-200 p-12 min-h-[500px] shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-canina-blue text-white rounded-[1.5rem] flex items-center justify-center shadow-xl">
|
||||
<Stethoscope className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-medical-gray-900 italic">نسخههای پزشکی {activePet.name}</h3>
|
||||
<p className="text-xs font-bold text-medical-gray-400 mt-1">پرونده نسخههای بارگذاری شده و نظرات تخصصی پزشکان کنینا</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{petPrescriptions.length === 0 ? (
|
||||
<div className="py-24 text-center space-y-6">
|
||||
<div className="w-24 h-24 bg-medical-gray-50 rounded-full flex items-center justify-center mx-auto text-medical-gray-200 shadow-inner">
|
||||
<Stethoscope className="w-12 h-12" />
|
||||
</div>
|
||||
<p className="text-medical-gray-400 font-black text-xl italic">هنوز نسخه پزشکی برای {activePet.name} ثبت نشده است.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{petPrescriptions.map((rx: any) => (
|
||||
<div
|
||||
key={rx.id}
|
||||
className="p-6 bg-medical-gray-50/70 rounded-[2.5rem] border border-medical-gray-200 flex flex-col md:flex-row items-start md:items-center justify-between gap-6"
|
||||
>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-black text-medical-gray-900">نسخه شماره #{rx.id.slice(0, 8)}</span>
|
||||
<span className={`text-[10px] font-black px-2.5 py-0.5 rounded-full ${
|
||||
rx.status === 'APPROVED' ? 'bg-green-100 text-green-700' : rx.status === 'REJECTED' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{rx.status === 'APPROVED' ? 'تایید شده توسط پزشک' : rx.status === 'REJECTED' ? 'عدم تایید' : 'در حال بررسی تخصصی'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-medical-gray-500 font-bold flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-canina-blue" />
|
||||
<span>تاریخ ثبت: {toPersian(new Date(rx.createdAt).toLocaleDateString("fa-IR", { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }))}</span>
|
||||
</div>
|
||||
{rx.notes && (
|
||||
<p className="text-xs text-medical-gray-600 bg-white p-3 rounded-xl border border-medical-gray-200">
|
||||
<strong className="text-medical-gray-800 block mb-0.5">یادداشت شما:</strong>
|
||||
{rx.notes}
|
||||
</p>
|
||||
)}
|
||||
{rx.adminNotes && (
|
||||
<p className="text-xs text-canina-blue bg-canina-blue/5 p-3 rounded-xl border border-canina-blue/10 font-bold">
|
||||
<strong className="text-canina-dark block mb-0.5">پاسخ و دستور مصرف پزشک کنینا:</strong>
|
||||
{rx.adminNotes.split('__PRESCRIBED_PRODUCTS__:')[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-3">
|
||||
<a
|
||||
href={rx.fileUrl.startsWith('http') ? rx.fileUrl : `${BASE_DOMAIN}${rx.fileUrl}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-4 py-2.5 bg-white text-canina-blue border border-canina-blue/20 rounded-xl text-xs font-black hover:bg-canina-blue hover:text-white transition-all shadow-xs"
|
||||
>
|
||||
مشاهده فایل نسخه
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
@ -1290,6 +1429,12 @@ export default function PetProfile({ initialView, advisorNeed, embedded = false
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Invoice Modal for Pet Purchase History */}
|
||||
<OrderDetailsModal
|
||||
isOpen={!!selectedInvoiceOrder}
|
||||
order={selectedInvoiceOrder}
|
||||
onClose={() => setSelectedInvoiceOrder(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Upload, CheckCircle2, X, Send, Stethoscope } from "lucide-react";
|
||||
import { Upload, CheckCircle2, X, Send, Stethoscope, Plus, Dog, Cat, Phone, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useUserStore } from "../lib/store/userStore";
|
||||
import { usePetStore } from "../lib/store/usePetStore";
|
||||
|
||||
interface PrescriptionUploadModalProps {
|
||||
isOpen: boolean;
|
||||
@ -10,13 +12,35 @@ interface PrescriptionUploadModalProps {
|
||||
}
|
||||
|
||||
export default function PrescriptionUploadModal({ isOpen, onClose }: PrescriptionUploadModalProps) {
|
||||
const { isLoggedIn, profile } = useUserStore();
|
||||
const { pets, activePetId, addPet } = usePetStore();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [petName, setPetName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [selectedPetOption, setSelectedPetOption] = useState<string>("active"); // petId or 'new'
|
||||
const [newPetName, setNewPetName] = useState("");
|
||||
const [newPetType, setNewPetType] = useState<"سگ" | "گربه">("سگ");
|
||||
const [newPetBreed, setNewPetBreed] = useState("");
|
||||
const [newPetAge, setNewPetAge] = useState<string>("");
|
||||
const [newPetWeight, setNewPetWeight] = useState<string>("");
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isLoggedIn && profile?.mobile) {
|
||||
setPhone(profile.mobile);
|
||||
}
|
||||
if (pets.length > 0) {
|
||||
setSelectedPetOption(activePetId || pets[0].id);
|
||||
} else {
|
||||
setSelectedPetOption("new");
|
||||
}
|
||||
}
|
||||
}, [isOpen, isLoggedIn, profile?.mobile, pets, activePetId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -27,9 +51,11 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setPetName("");
|
||||
setPhone("");
|
||||
setNotes("");
|
||||
setNewPetName("");
|
||||
setNewPetBreed("");
|
||||
setNewPetAge("");
|
||||
setNewPetWeight("");
|
||||
setIsSubmitting(false);
|
||||
setIsSuccess(false);
|
||||
};
|
||||
@ -48,10 +74,25 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
|
||||
const cleanPhone = phone.trim();
|
||||
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||
toast.error("شماره همراه وارد شده معتبر نیست. (مثال: ۰۹۱۲۳۴۵۶۷۸۹)");
|
||||
toast.error("شماره همراه معتبر نیست. (مثال: ۰۹۱۲۳۴۵۶۷۸۹)");
|
||||
return;
|
||||
}
|
||||
|
||||
let finalPetId: string | undefined = undefined;
|
||||
let finalPetName: string | undefined = undefined;
|
||||
|
||||
if (selectedPetOption !== "new") {
|
||||
finalPetId = selectedPetOption;
|
||||
const found = pets.find(p => p.id === selectedPetOption);
|
||||
if (found) finalPetName = found.name;
|
||||
} else {
|
||||
if (!newPetName.trim()) {
|
||||
toast.error("لطفاً نام همدم (پت) را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
finalPetName = newPetName.trim();
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const api = (await import("../lib/services/api")).default;
|
||||
@ -62,17 +103,43 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
});
|
||||
const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name;
|
||||
|
||||
// Submit prescription to backend with petName and phone for automatic user & pet profile creation
|
||||
await api.post("/prescriptions", {
|
||||
const payload: Record<string, any> = {
|
||||
fileUrl,
|
||||
phone: cleanPhone,
|
||||
petName: petName.trim() || undefined,
|
||||
notes: notes.trim() || undefined
|
||||
});
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
if (finalPetId) {
|
||||
payload.petId = finalPetId;
|
||||
payload.petName = finalPetName;
|
||||
} else {
|
||||
payload.petName = finalPetName;
|
||||
payload.petType = newPetType;
|
||||
payload.petBreed = newPetBreed.trim() || undefined;
|
||||
payload.petAge = newPetAge ? parseFloat(newPetAge) : 0;
|
||||
payload.petWeight = newPetWeight ? parseFloat(newPetWeight) : 0;
|
||||
}
|
||||
|
||||
await api.post("/prescriptions", payload);
|
||||
|
||||
if (selectedPetOption === "new" && finalPetName) {
|
||||
addPet({
|
||||
name: finalPetName,
|
||||
type: newPetType,
|
||||
breed: newPetBreed.trim() || "نامشخص",
|
||||
age: newPetAge ? parseFloat(newPetAge) : 0,
|
||||
weight: newPetWeight ? parseFloat(newPetWeight) : 0,
|
||||
activityLevel: "متوسط",
|
||||
medicalConditions: [],
|
||||
reminders: [],
|
||||
logs: [],
|
||||
consumptions: []
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSuccess(true);
|
||||
toast.success("نسخه پزشکی شما با موفقیت ثبت و ارسال شد.");
|
||||
toast.success("نسخه پزشکی شما با موفقیت ثبت شد.");
|
||||
} catch (error) {
|
||||
console.error("[PrescriptionUpload] Submission error:", error);
|
||||
setIsSubmitting(false);
|
||||
@ -82,18 +149,18 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" dir="rtl font-vazir">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm font-vazir" dir="rtl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-white rounded-3xl max-w-lg w-full overflow-hidden shadow-2xl border border-medical-gray-200"
|
||||
className="bg-white rounded-3xl max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-2xl border border-medical-gray-200 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-canina-blue text-white p-6 relative">
|
||||
<div className="bg-canina-blue text-white p-6 relative shrink-0">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute left-5 top-5 text-white/80 hover:text-white bg-white/10 p-1.5 rounded-full transition-colors"
|
||||
className="absolute left-5 top-5 text-white/80 hover:text-white bg-white/10 p-1.5 rounded-full transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@ -102,14 +169,14 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
<Stethoscope className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black font-vazir">خرید سریع با نسخه دامپزشک</h3>
|
||||
<p className="text-xs text-blue-100 mt-0.5">آپلود نسخه برای تامین فوری و مشاوره تخصصی</p>
|
||||
<h3 className="text-lg font-black font-vazir">بررسی تخصصی نسخه دامپزشک</h3>
|
||||
<p className="text-xs text-blue-100 mt-0.5">آپلود نسخه جهت تامین فوری دارو و مکملهای اورجینال آلمان</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<div className="p-6 flex-1">
|
||||
{isSuccess ? (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto">
|
||||
@ -117,11 +184,11 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
</div>
|
||||
<h4 className="text-lg font-black text-medical-gray-900">نسخه با موفقیت ثبت شد</h4>
|
||||
<p className="text-xs text-medical-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
کارشناسان و دامپزشکان کنینا نسخه شما را بررسی کرده و ظرف حداکثر ۱۵ دقیقه جهت هماهنگی ارسال دارو با شما تماس خواهند گرفت.
|
||||
تیم پزشکی و کارشناسان کنینا نسخه شما را بررسی کرده و اقلام تجویزی را به پرونده شما اضافه میکنند. به محض آمادهسازی، پیامک اطلاعرسانی برای شما ارسال خواهد شد.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-6 py-2.5 bg-canina-blue text-white rounded-xl text-xs font-black hover:bg-canina-dark transition-all"
|
||||
className="px-8 py-3 bg-canina-blue text-white rounded-xl text-xs font-black hover:bg-canina-dark transition-all cursor-pointer shadow-md shadow-canina-blue/20"
|
||||
>
|
||||
متوجه شدم
|
||||
</button>
|
||||
@ -130,7 +197,7 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Upload Zone */}
|
||||
<div>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-2">تصویر نسخه پزشکی *</label>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-2">تصویر یا فایل نسخه دامپزشک *</label>
|
||||
<div className="border-2 border-dashed border-medical-gray-300 hover:border-canina-blue rounded-2xl p-4 text-center cursor-pointer transition-colors relative bg-medical-gray-50/50">
|
||||
<input
|
||||
type="file"
|
||||
@ -138,71 +205,173 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
onChange={handleFileChange}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Upload className="w-8 h-8 text-medical-gray-400 mx-auto mb-2" />
|
||||
{file ? (
|
||||
<span className="text-xs font-bold text-canina-blue block">{file.name}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs font-bold text-medical-gray-600 block">کلیک کنید یا فایل نسخه را بکشید</span>
|
||||
<span className="text-[10px] text-medical-gray-400 block mt-1">فرمتهای JPG، PNG یا PDF (حداکثر ۵ مگابایت)</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-canina-blue/10 flex items-center justify-center text-canina-blue">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
{file ? (
|
||||
<div className="text-xs font-bold text-canina-blue truncate max-w-xs">{file.name}</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-bold text-medical-gray-700">برای انتخاب فایل یا عکس نسخه کلیک کنید</p>
|
||||
<span className="text-[10px] text-medical-gray-400">فرمتهای مجاز: JPG, PNG, PDF (حداکثر ۱۰ مگابایت)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">نام پت (اختیاری)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={petName}
|
||||
onChange={(e) => setPetName(e.target.value)}
|
||||
placeholder="مثال: لوسی"
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-canina-blue/20 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">شماره همراه جهت هماهنگی *</label>
|
||||
{/* Pet Selection or Inline Creation */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-black text-medical-gray-700">این نسخه برای کدام همدم است؟ *</label>
|
||||
{pets.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
{pets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPetOption(p.id)}
|
||||
className={`p-2.5 rounded-xl border text-xs font-bold flex items-center gap-2 transition-all cursor-pointer ${
|
||||
selectedPetOption === p.id
|
||||
? "border-canina-blue bg-canina-blue/10 text-canina-blue shadow-xs font-black"
|
||||
: "border-medical-gray-200 text-medical-gray-600 hover:bg-medical-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p.type === "سگ" ? <Dog className="w-4 h-4 shrink-0" /> : <Cat className="w-4 h-4 shrink-0 text-amber-500" />}
|
||||
<span className="truncate">{p.name} ({p.breed || p.type})</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPetOption("new")}
|
||||
className={`p-2.5 rounded-xl border text-xs font-bold flex items-center justify-center gap-1.5 transition-all cursor-pointer ${
|
||||
selectedPetOption === "new"
|
||||
? "border-canina-blue bg-canina-blue/10 text-canina-blue font-black"
|
||||
: "border-dashed border-medical-gray-300 text-medical-gray-500 hover:bg-medical-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>همدم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Pet Info Form if "new" or no pets */}
|
||||
{(selectedPetOption === "new" || pets.length === 0) && (
|
||||
<div className="p-3.5 bg-medical-gray-50/80 rounded-2xl border border-medical-gray-200 space-y-3 animate-in fade-in duration-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-black text-medical-gray-800">مشخصات همدم جدید:</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewPetType("سگ")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
newPetType === "سگ" ? "bg-canina-blue text-white shadow-xs" : "bg-white text-medical-gray-600 border border-medical-gray-200"
|
||||
}`}
|
||||
>
|
||||
سگ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewPetType("گربه")}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
newPetType === "گربه" ? "bg-canina-blue text-white shadow-xs" : "bg-white text-medical-gray-600 border border-medical-gray-200"
|
||||
}`}
|
||||
>
|
||||
گربه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="نام حیوان خانگی *"
|
||||
value={newPetName}
|
||||
onChange={(e) => setNewPetName(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="نژاد (مثلاً ژرمن، پرشین)"
|
||||
value={newPetBreed}
|
||||
onChange={(e) => setNewPetBreed(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="سن (سال)"
|
||||
value={newPetAge}
|
||||
onChange={(e) => setNewPetAge(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
placeholder="وزن (کیلوگرم)"
|
||||
value={newPetWeight}
|
||||
onChange={(e) => setNewPetWeight(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-1">
|
||||
شماره همراه جهت هماهنگی و پیامک تایید *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
required
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="۰۹۱۲..."
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-canina-blue/20 outline-none text-left"
|
||||
dir="ltr"
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl py-2.5 px-3 text-xs font-mono font-bold text-medical-gray-900 focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
<Phone className="w-4 h-4 text-medical-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
{isLoggedIn && profile?.mobile && (
|
||||
<span className="text-[10px] text-green-600 font-bold block mt-1">
|
||||
✓ شماره از حساب کاربری شما شناسایی شد.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-1.5">توضیحات تکمیلی (اختیاری)</label>
|
||||
<label className="block text-xs font-black text-medical-gray-700 mb-1">توضیحات و علائم خاص (اختیاری)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="نکات پزشک، حساسیت دارویی یا شرایط خاص..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="اگر توضیحات خاصی درباره سابقه درمانی پت دارید بنویسید..."
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-canina-blue/20 outline-none"
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-xl p-3 text-xs font-medium text-medical-gray-900 focus:outline-none focus:ring-2 focus:ring-canina-blue/20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-3 border-t border-medical-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2.5 rounded-xl text-xs font-bold text-medical-gray-500 hover:bg-medical-gray-100 transition-colors"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2.5 bg-canina-blue text-white rounded-xl text-xs font-black hover:bg-canina-dark transition-all flex items-center gap-2 shadow-md shadow-canina-blue/20 disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{isSubmitting ? "در حال ارسال..." : "ارسال نسخه و سفارش سریع"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 bg-canina-blue text-white rounded-xl font-black text-xs hover:bg-canina-dark transition-all flex items-center justify-center gap-2 shadow-lg shadow-canina-blue/20 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>ارسال نسخه و ثبت درخواست تامین</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ import TopUpModal from "./TopUpModal";
|
||||
import { ticketService, Ticket, TicketMessage } from "../lib/services/ticketService";
|
||||
import { usePetStore } from "../lib/store/usePetStore";
|
||||
import PetProfile from "./PetProfile";
|
||||
import api, { BASE_DOMAIN } from "../lib/services/api";
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
@ -33,17 +34,37 @@ export default function UserDashboard() {
|
||||
}, [isLoggedIn, router]);
|
||||
|
||||
const { orders } = useCartStore();
|
||||
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets" | "pets" | "overview">("profile");
|
||||
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets" | "pets" | "prescriptions" | "overview">("profile");
|
||||
|
||||
// Set tab from URL query param if present
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam && ['profile', 'orders', 'wallet', 'addresses', 'tickets', 'pets'].includes(tabParam)) {
|
||||
setActiveTab(tabParam as "profile" | "orders" | "wallet" | "addresses" | "tickets" | "pets");
|
||||
if (tabParam && ['profile', 'orders', 'wallet', 'addresses', 'tickets', 'pets', 'prescriptions'].includes(tabParam)) {
|
||||
setActiveTab(tabParam as "profile" | "orders" | "wallet" | "addresses" | "tickets" | "pets" | "prescriptions");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
||||
const [prescriptions, setPrescriptions] = useState<any[]>([]);
|
||||
const [isLoadingPrescriptions, setIsLoadingPrescriptions] = useState(false);
|
||||
|
||||
const fetchPrescriptions = React.useCallback(async () => {
|
||||
try {
|
||||
setIsLoadingPrescriptions(true);
|
||||
const res = await api.get('/prescriptions');
|
||||
setPrescriptions(Array.isArray(res.data) ? res.data : (res.data?.data || []));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoadingPrescriptions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'prescriptions') {
|
||||
fetchPrescriptions();
|
||||
}
|
||||
}, [activeTab, fetchPrescriptions]);
|
||||
|
||||
// Tickets State
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
@ -343,6 +364,7 @@ export default function UserDashboard() {
|
||||
{ id: "profile", label: "اطلاعات فردی", icon: <UserCircle className="w-5 h-5" /> },
|
||||
{ id: "pets", label: "پتهای من", icon: <Dog className="w-5 h-5" /> },
|
||||
{ id: "orders", label: "سفارشات من", icon: <Package className="w-5 h-5" /> },
|
||||
{ id: "prescriptions", label: "نسخههای من", icon: <Stethoscope className="w-5 h-5" /> },
|
||||
{ id: "wallet", label: "کیف پول", icon: <Wallet className="w-5 h-5" /> },
|
||||
{ id: "addresses", label: "آدرسها", icon: <MapPin className="w-5 h-5" /> },
|
||||
{ id: "tickets", label: "پشتیبانی و مشاوره", icon: <MessageSquare className="w-5 h-5" /> },
|
||||
@ -377,8 +399,8 @@ export default function UserDashboard() {
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as 'overview' | 'orders' | 'addresses' | 'wallet' | 'tickets')}
|
||||
className={`flex items-center justify-center gap-2 px-3 sm:px-4 py-2.5 sm:py-3 rounded-xl sm:rounded-2xl transition-all font-bold text-xs sm:text-sm ${activeTab === tab.id ? "bg-canina-blue text-white shadow-lg shadow-canina-blue/20" : "text-medical-gray-600 hover:bg-medical-gray-100 bg-medical-gray-50"}`}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center justify-center gap-2 px-3 sm:px-4 py-2.5 sm:py-3 rounded-xl sm:rounded-2xl transition-all font-bold text-xs sm:text-sm cursor-pointer ${activeTab === tab.id ? "bg-canina-blue text-white shadow-lg shadow-canina-blue/20" : "text-medical-gray-600 hover:bg-medical-gray-100 bg-medical-gray-50"}`}
|
||||
>
|
||||
<div className="flex-shrink-0">{tab.icon}</div>
|
||||
<span>{tab.label}</span>
|
||||
@ -1067,7 +1089,106 @@ export default function UserDashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{activeTab === "prescriptions" && (
|
||||
<div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-medical-gray-900 italic flex items-center gap-2">
|
||||
<Stethoscope className="w-6 h-6 text-canina-blue" />
|
||||
نسخههای پزشکی من
|
||||
</h3>
|
||||
<p className="text-xs font-bold text-medical-gray-400 mt-1">
|
||||
پیگیری وضعیت نسخههای بارگذاری شده و توصیههای دارویی دامپزشک
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/?rx=1')}
|
||||
className="px-5 py-3 bg-canina-blue text-white rounded-2xl text-xs font-black hover:bg-canina-dark transition-all flex items-center gap-2 shadow-lg shadow-canina-blue/20 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>ارسال نسخه جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingPrescriptions ? (
|
||||
<div className="py-20 text-center text-medical-gray-400 font-bold text-sm">
|
||||
در حال بارگذاری نسخهها...
|
||||
</div>
|
||||
) : prescriptions.length === 0 ? (
|
||||
<div className="py-20 text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-medical-gray-50 text-medical-gray-300 rounded-full flex items-center justify-center mx-auto">
|
||||
<Stethoscope className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-sm font-bold text-medical-gray-400">هنوز هیچ نسخهای ثبت نکردهاید.</p>
|
||||
<button
|
||||
onClick={() => router.push('/?rx=1')}
|
||||
className="px-6 py-2.5 bg-medical-gray-900 text-white rounded-xl text-xs font-bold hover:bg-canina-blue transition-all cursor-pointer"
|
||||
>
|
||||
ثبت اولین نسخه
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{prescriptions.map((rx: any) => (
|
||||
<div
|
||||
key={rx.id}
|
||||
className="p-5 sm:p-6 bg-medical-gray-50 border border-medical-gray-200 rounded-[1.5rem] sm:rounded-[2rem] space-y-3 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 border-b border-medical-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-black text-medical-gray-900">نسخه #{rx.id.slice(0, 8)}</span>
|
||||
{rx.pet && (
|
||||
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-[10px] font-bold rounded-lg border border-purple-100">
|
||||
🐾 {rx.pet.name} ({rx.pet.breed || rx.pet.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-xs font-black rounded-full ${
|
||||
rx.status === 'APPROVED' ? 'bg-green-100 text-green-700' :
|
||||
rx.status === 'REJECTED' ? 'bg-red-100 text-red-700' :
|
||||
'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{rx.status === 'APPROVED' ? 'تایید شده' : rx.status === 'REJECTED' ? 'عدم تایید' : 'در حال بررسی'}
|
||||
</span>
|
||||
<span className="text-[11px] text-medical-gray-400 font-bold">
|
||||
{toPersian(new Date(rx.createdAt).toLocaleDateString('fa-IR'))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rx.notes && (
|
||||
<p className="text-xs text-medical-gray-600">
|
||||
<strong className="text-medical-gray-800">توضیحات شما: </strong>
|
||||
{rx.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rx.adminNotes && (
|
||||
<div className="p-3.5 bg-canina-blue/5 border border-canina-blue/10 rounded-xl text-xs text-canina-blue leading-relaxed font-bold">
|
||||
<strong className="text-canina-dark block mb-1">پاسخ دامپزشک کنینا:</strong>
|
||||
{rx.adminNotes.split('__PRESCRIBED_PRODUCTS__:')[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-2">
|
||||
<a
|
||||
href={rx.fileUrl.startsWith('http') ? rx.fileUrl : `${BASE_DOMAIN}${rx.fileUrl}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-4 py-2 bg-white border border-medical-gray-200 text-medical-gray-700 hover:text-canina-blue hover:border-canina-blue rounded-xl text-xs font-bold transition-all"
|
||||
>
|
||||
مشاهده تصویر نسخه
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Widgets (Wallet & Charity) */}
|
||||
|
||||
@ -11,6 +11,7 @@ const getBaseURL = () => {
|
||||
};
|
||||
|
||||
const baseURL = getBaseURL();
|
||||
export const BASE_DOMAIN = process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || (typeof window !== 'undefined' && process.env.NODE_ENV === 'development' ? 'http://localhost:4001' : '');
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
success: boolean;
|
||||
|
||||
@ -38,6 +38,8 @@ export interface PetProfile {
|
||||
weight: number;
|
||||
activityLevel: "کم" | "متوسط" | "زیاد";
|
||||
medicalConditions: string[];
|
||||
extraNotes?: string;
|
||||
notes?: string;
|
||||
imageUrl?: string;
|
||||
image?: string;
|
||||
reminders: Reminder[];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2
graphify-out/cache/stat-index.json
vendored
2
graphify-out/cache/stat-index.json
vendored
File diff suppressed because one or more lines are too long
50129
graphify-out/graph.json
50129
graphify-out/graph.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user