feat: complete tickets & vet consultation, fix wallet balance overflow, b2b toggle, and transaction manual verification
All checks were successful
Deploy Canina / deploy (push) Successful in 1m51s

This commit is contained in:
parsa aghaei 2026-08-16 18:01:43 +03:30
parent 1aa9d6e34d
commit 42707c04cf
24 changed files with 1856 additions and 139 deletions

View File

@ -29,6 +29,7 @@ model User {
prescriptions Prescription[]
partnerAccount PartnerAccount?
paymentTransactions PaymentTransaction[]
tickets Ticket[]
@@map("users")
}
@ -218,6 +219,7 @@ model Pet {
reminders Reminder[]
healthLogs HealthLog[]
prescriptions Prescription[]
tickets Ticket[]
@@map("pets")
}
@ -629,3 +631,44 @@ model SmsLog {
@@map("sms_logs")
}
model Ticket {
id String @id @default(uuid()) @db.Uuid
ticketNumber String @unique @map("ticket_number") @db.VarChar(50)
userId String @map("user_id") @db.Uuid
petId String? @map("pet_id") @db.Uuid
subject String @db.VarChar(255)
category String @default("VET_CONSULTATION") @db.VarChar(50) // VET_CONSULTATION, ORDER_SUPPORT, PRODUCT_INQUIRY, GENERAL
priority String @default("MEDIUM") @db.VarChar(20) // LOW, MEDIUM, HIGH, URGENT
status String @default("OPEN") @db.VarChar(30) // OPEN, ANSWERED, IN_PROGRESS, CLOSED
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull)
messages TicketMessage[]
@@index([userId])
@@index([status])
@@index([category])
@@index([createdAt])
@@map("tickets")
}
model TicketMessage {
id String @id @default(uuid()) @db.Uuid
ticketId String @map("ticket_id") @db.Uuid
senderId String? @map("sender_id") @db.Uuid
senderRole String @default("USER") @map("sender_role") @db.VarChar(30) // USER, ADMIN, VET_DOCTOR
senderName String @map("sender_name") @db.VarChar(100)
message String @db.Text
attachment String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
@@index([ticketId])
@@index([createdAt])
@@map("ticket_messages")
}

View File

@ -477,6 +477,9 @@ export class AdminService {
},
},
coupon: true,
paymentTransactions: {
orderBy: { createdAt: 'desc' },
},
},
}),
this.prisma.order.count({ where }),

View File

@ -28,6 +28,7 @@ import { IngredientsModule } from './ingredients/ingredients.module';
import { PrescriptionsModule } from './prescriptions/prescriptions.module';
import { B2BModule } from './b2b/b2b.module';
import { PaymentModule } from './payment/payment.module';
import { TicketsModule } from './tickets/tickets.module';
import { Request, Response, NextFunction } from 'express';
@ -44,6 +45,7 @@ import { Request, Response, NextFunction } from 'express';
OrdersModule,
SettingsModule,
PaymentModule,
TicketsModule,
ThrottlerModule.forRoot([
{
ttl: 60000,

View File

@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
@ -11,6 +12,8 @@ import { Prisma } from '@prisma/client';
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private prisma: PrismaService,
private smsService: SmsService,
@ -186,7 +189,7 @@ export class OrdersService {
);
}
return this.prisma.$transaction(async (tx) => {
const createdWalletOrder = await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
@ -236,6 +239,21 @@ export class OrdersService {
},
});
});
// Send Order Confirmation SMS for wallet orders
if (user.mobile) {
this.smsService
.sendOrderConfirmation(
user.mobile,
trackingNumber,
Number(finalAmount).toLocaleString('fa-IR'),
)
.catch((err) => {
this.logger.warn(`Failed to send wallet order SMS: ${err}`);
});
}
return createdWalletOrder;
}
const isOnline = createOrderDto.paymentMethod === 'online';

View File

@ -212,6 +212,30 @@ export class PaymentController {
return this.paymentService.adminLiveInquiry(id);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()
@Post('admin/manual-verify/:id')
@ApiOperation({ summary: 'تایید دستی تراکنش یا فیش واریزی کارت‌به‌کارت توسط ادمین' })
async manualVerify(
@Param('id') id: string,
@Body() body: { adminNote?: string },
) {
return this.paymentService.adminManualVerify(id, body.adminNote);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()
@Post('admin/manual-reject/:id')
@ApiOperation({ summary: 'رد دستی تراکنش پرداخت توسط ادمین' })
async manualReject(
@Param('id') id: string,
@Body() body: { reason?: string },
) {
return this.paymentService.adminManualReject(id, body.reason);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()

View File

@ -478,16 +478,22 @@ export class PaymentService {
});
// Send SMS notification if order payment
if (transaction.type === 'ORDER' && transaction.order?.user?.mobile) {
const mobile = transaction.order.user.mobile;
const customerMobile =
transaction.order?.user?.mobile ||
transaction.user?.mobile ||
(transaction.rawRequest as { mobile?: string })?.mobile;
if (transaction.type === 'ORDER' && customerMobile) {
const trackingNum =
transaction.order.trackingNumber || transaction.order.id;
transaction.order?.trackingNumber ||
transaction.orderId ||
transaction.id;
const formattedAmount = Number(transaction.amount).toLocaleString(
'fa-IR',
);
this.smsService
.sendOrderConfirmation(mobile, trackingNum, formattedAmount)
.sendOrderConfirmation(customerMobile, trackingNum, formattedAmount)
.catch((err) => {
this.logger.warn(`Could not send SMS confirmation: ${err}`);
});
@ -842,16 +848,101 @@ export class PaymentService {
return {
transactionId: transaction.id,
trackId: transaction.trackId,
currentDbStatus: transaction.status,
gatewayResponse: inquiryRes,
status: inquiryRes.status,
statusMessage: statusMsg,
result: inquiryRes.result,
resultMessage: resultMsg,
inquiredAt: new Date(),
amount: inquiryRes.amount,
refNumber: inquiryRes.refNumber,
cardNumber: inquiryRes.cardNumber,
paidAt: inquiryRes.paidAt,
rawInquiryResponse: inquiryRes,
};
}
/**
* 9. Gateway Health Check for Admin
* 9. Admin: Manually Approve / Verify a Transaction (e.g. Card-to-Card receipt or bank transfer)
*/
async adminManualVerify(transactionId: string, adminNote?: string) {
const transaction = await this.prisma.paymentTransaction.findUnique({
where: { id: transactionId },
include: { order: true, user: true },
});
if (!transaction) {
throw new NotFoundException('تراکنش یافت نشد');
}
if (transaction.status === 'VERIFIED') {
throw new BadRequestException('این تراکنش قبلاً تایید شده است');
}
await this.prisma.$transaction(async (tx) => {
await tx.paymentTransaction.update({
where: { id: transactionId },
data: {
status: 'VERIFIED',
message: adminNote || 'تایید دستی توسط مدیر سیستم (کارت به کارت / فیش بانکی)',
paidAt: new Date(),
},
});
if (transaction.type === 'WALLET_TOPUP') {
await tx.user.update({
where: { id: transaction.userId },
data: {
walletBalance: { increment: Number(transaction.amount) },
},
});
await tx.walletTransaction.create({
data: {
userId: transaction.userId,
amount: transaction.amount,
type: 'deposit',
status: 'completed',
description: `افزایش موجودی دستی تایید شده توسط ادمین (${adminNote || 'کارت به کارت'})`,
},
});
} else if (transaction.type === 'ORDER' && transaction.orderId) {
await tx.order.update({
where: { id: transaction.orderId },
data: {
status: 'processing',
paymentMethod: 'card_to_card',
},
});
}
});
return { success: true, message: 'تراکنش با موفقیت به صورت دستی تایید و اعمال شد' };
}
/**
* 10. Admin: Manually Reject a Transaction
*/
async adminManualReject(transactionId: string, reason?: string) {
const transaction = await this.prisma.paymentTransaction.findUnique({
where: { id: transactionId },
});
if (!transaction) {
throw new NotFoundException('تراکنش یافت نشد');
}
await this.prisma.paymentTransaction.update({
where: { id: transactionId },
data: {
status: 'FAILED',
message: reason || 'رد شده توسط ادمین سیستم',
},
});
return { success: true, message: 'تراکنش به عنوان ناموفق/رد شده علامت‌گذاری شد' };
}
/**
* 11. Gateway Health Check for Admin
*/
async checkGatewayHealth() {
return this.zibalService.checkHealth();

View File

@ -0,0 +1,63 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsArray } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTicketDto {
@ApiProperty({ description: 'موضوع تیکت یا عنوان مشاوره', example: 'مشاوره مصرف مکمل کانینوکال' })
@IsString()
@IsNotEmpty()
subject: string;
@ApiProperty({ description: 'متن پیام یا شرح وضعیت', example: 'سگ من ۵ ماهشه و می‌خواستم بدونم...' })
@IsString()
@IsNotEmpty()
message: string;
@ApiPropertyOptional({ description: 'دسته‌بندی تیکت', example: 'VET_CONSULTATION', enum: ['VET_CONSULTATION', 'ORDER_SUPPORT', 'PRODUCT_INQUIRY', 'GENERAL'] })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: 'اولویت تیکت', example: 'MEDIUM', enum: ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] })
@IsOptional()
@IsString()
priority?: string;
@ApiPropertyOptional({ description: 'شناسه حیوان خانگی مرتبط (اختیاری)', example: '9a8f4171-86bb-4236-9d6d-e4bc60255156' })
@IsOptional()
@IsString()
petId?: string;
@ApiPropertyOptional({ description: 'لینک تصویر آزمایش یا نسخه ضمیمه شده' })
@IsOptional()
@IsString()
attachment?: string;
}
export class ReplyTicketDto {
@ApiProperty({ description: 'متن پاسخ تیکت', example: 'با سلام، دوز مصرفی پیشنهادی...' })
@IsString()
@IsNotEmpty()
message: string;
@ApiPropertyOptional({ description: 'لینک فایل ضمیمه' })
@IsOptional()
@IsString()
attachment?: string;
@ApiPropertyOptional({ description: 'تغییر وضعیت تیکت (توسط ادمین)', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] })
@IsOptional()
@IsString()
status?: string;
}
export class AdminUpdateTicketDto {
@ApiPropertyOptional({ description: 'وضعیت جدید تیکت', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] })
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ description: 'اولویت تیکت', enum: ['LOW', 'MEDIUM', 'HIGH', 'URGENT'] })
@IsOptional()
@IsString()
priority?: string;
}

View File

@ -0,0 +1,32 @@
import { IsOptional, IsString, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class TicketQueryDto {
@ApiPropertyOptional({ description: 'شماره صفحه', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
page?: number = 1;
@ApiPropertyOptional({ description: 'تعداد در هر صفحه', default: 15 })
@IsOptional()
@Type(() => Number)
@IsNumber()
limit?: number = 15;
@ApiPropertyOptional({ description: 'فیلتر بر اساس وضعیت', enum: ['OPEN', 'IN_PROGRESS', 'ANSWERED', 'CLOSED'] })
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({ description: 'فیلتر بر اساس دسته‌بندی', enum: ['VET_CONSULTATION', 'ORDER_SUPPORT', 'PRODUCT_INQUIRY', 'GENERAL'] })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: 'جستجو در موضوع، شماره تیکت یا مشخصات کاربر' })
@IsOptional()
@IsString()
search?: string;
}

View File

@ -0,0 +1,96 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
Req,
} from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { CreateTicketDto, ReplyTicketDto, AdminUpdateTicketDto } from './dto/create-ticket.dto';
import { TicketQueryDto } from './dto/ticket-query.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
} from '@nestjs/swagger';
@ApiTags('Tickets & Vet Consultation - تیکتینگ، پشتیبانی و مشاوره آنلاین دامپزشک')
@Controller('tickets')
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post()
@ApiOperation({ summary: 'ثبت تیکت جدید یا درخواست مشاوره دامپزشک توسط کاربر' })
async createTicket(
@Req() req: { user: { id: string } },
@Body() dto: CreateTicketDto,
) {
return this.ticketsService.createTicket(req.user.id, dto);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('my-tickets')
@ApiOperation({ summary: 'دریافت لیست تیکت‌های کاربر لاگین شده' })
async getMyTickets(@Req() req: { user: { id: string } }) {
return this.ticketsService.getUserTickets(req.user.id);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get(':id')
@ApiOperation({ summary: 'دریافت رشته پیام‌ها و جزئیات تیکت' })
async getTicketDetails(
@Req() req: { user: { id: string; role?: string } },
@Param('id') id: string,
) {
const isAdmin = req.user.role?.includes('Admin') || false;
return this.ticketsService.getTicketDetails(id, req.user.id, isAdmin);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post(':id/messages')
@ApiOperation({ summary: 'ارسال پاسخ جدید به تیکت توسط کاربر یا ادمین' })
async replyTicket(
@Req() req: { user: { id: string; role?: string } },
@Param('id') id: string,
@Body() dto: ReplyTicketDto,
) {
const isAdmin = req.user.role?.includes('Admin') || false;
return this.ticketsService.replyToTicket(id, req.user.id, dto, isAdmin);
}
// --- ADMIN ENDPOINTS ---
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()
@Get('admin/all')
@ApiOperation({ summary: 'مشاهده و فیلتر تمامی تیکت‌ها در پنل ادمین' })
async getAdminTickets(@Query() query: TicketQueryDto) {
return this.ticketsService.getAdminTickets(query);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()
@Put('admin/:id/status')
@ApiOperation({ summary: 'تغییر وضعیت و اولویت تیکت توسط ادمین' })
async updateTicketStatus(
@Param('id') id: string,
@Body() dto: AdminUpdateTicketDto,
) {
return this.ticketsService.updateTicketStatus(id, dto);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TicketsService } from './tickets.service';
import { TicketsController } from './tickets.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TicketsController],
providers: [TicketsService],
exports: [TicketsService],
})
export class TicketsModule {}

View File

@ -0,0 +1,276 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTicketDto, ReplyTicketDto, AdminUpdateTicketDto } from './dto/create-ticket.dto';
import { TicketQueryDto } from './dto/ticket-query.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class TicketsService {
private readonly logger = new Logger(TicketsService.name);
constructor(private readonly prisma: PrismaService) {}
private generateTicketNumber(): string {
const random = Math.floor(10000 + Math.random() * 90000);
return `TK-${random}`;
}
/**
* 1. Create a new support ticket / veterinary consultation
*/
async createTicket(userId: string, dto: CreateTicketDto) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
const ticketNumber = this.generateTicketNumber();
const senderName = `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.mobile;
const ticket = await this.prisma.ticket.create({
data: {
ticketNumber,
userId,
petId: dto.petId || null,
subject: dto.subject,
category: dto.category || 'VET_CONSULTATION',
priority: dto.priority || 'MEDIUM',
status: 'OPEN',
messages: {
create: {
senderId: userId,
senderRole: user.role.includes('Admin') ? 'ADMIN' : 'USER',
senderName,
message: dto.message,
attachment: dto.attachment || null,
},
},
},
include: {
pet: true,
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
return {
success: true,
message: 'تیکت شما با موفقیت ثبت شد و به زودی توسط کارشناسان پاسخ داده می‌شود.',
data: ticket,
};
}
/**
* 2. Get tickets of a specific user
*/
async getUserTickets(userId: string) {
const tickets = await this.prisma.ticket.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
include: {
pet: true,
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
return {
success: true,
data: tickets,
};
}
/**
* 3. Get single ticket details
*/
async getTicketDetails(ticketId: string, userId?: string, isAdmin: boolean = false) {
const ticket = await this.prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
email: true,
role: true,
},
},
pet: true,
messages: {
orderBy: { createdAt: 'asc' },
},
},
});
if (!ticket) {
throw new NotFoundException('تیکت یافت نشد');
}
if (!isAdmin && userId && ticket.userId !== userId) {
throw new ForbiddenException('شما دسترسی به این تیکت را ندارید');
}
return {
success: true,
data: ticket,
};
}
/**
* 4. Reply to a ticket (User or Admin)
*/
async replyToTicket(
ticketId: string,
senderId: string,
dto: ReplyTicketDto,
isAdmin: boolean = false,
) {
const ticket = await this.prisma.ticket.findUnique({
where: { id: ticketId },
include: { user: true },
});
if (!ticket) {
throw new NotFoundException('تیکت یافت نشد');
}
if (!isAdmin && ticket.userId !== senderId) {
throw new ForbiddenException('شما دسترسی به این تیکت را ندارید');
}
const sender = await this.prisma.user.findUnique({ where: { id: senderId } });
const senderRole = isAdmin ? (sender?.role.includes('Doctor') ? 'VET_DOCTOR' : 'ADMIN') : 'USER';
const senderName = isAdmin
? `${sender?.firstName || 'پشتیبان'} ${sender?.lastName || 'کنینا'}`.trim()
: `${sender?.firstName || ''} ${sender?.lastName || ''}`.trim() || sender?.mobile || 'کاربر';
const newStatus = isAdmin ? (dto.status || 'ANSWERED') : 'IN_PROGRESS';
const [newMessage] = await this.prisma.$transaction([
this.prisma.ticketMessage.create({
data: {
ticketId,
senderId,
senderRole,
senderName,
message: dto.message,
attachment: dto.attachment || null,
},
}),
this.prisma.ticket.update({
where: { id: ticketId },
data: {
status: newStatus,
updatedAt: new Date(),
},
}),
]);
return {
success: true,
message: 'پاسخ با موفقیت ارسال شد',
data: newMessage,
};
}
/**
* 5. Admin: List all tickets with filtering & pagination
*/
async getAdminTickets(query: TicketQueryDto) {
const page = Math.max(1, Number(query.page) || 1);
const limit = Math.max(1, Math.min(100, Number(query.limit) || 15));
const skip = (page - 1) * limit;
const where: Prisma.TicketWhereInput = {};
if (query.status && query.status !== 'ALL') {
where.status = query.status;
}
if (query.category && query.category !== 'ALL') {
where.category = query.category;
}
if (query.search) {
const s = query.search.trim();
where.OR = [
{ ticketNumber: { contains: s, mode: 'insensitive' } },
{ subject: { contains: s, mode: 'insensitive' } },
{
user: {
OR: [
{ firstName: { contains: s, mode: 'insensitive' } },
{ lastName: { contains: s, mode: 'insensitive' } },
{ mobile: { contains: s } },
],
},
},
];
}
const [tickets, total] = await Promise.all([
this.prisma.ticket.findMany({
where,
skip,
take: limit,
orderBy: { updatedAt: 'desc' },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
},
},
pet: true,
messages: {
take: 1,
orderBy: { createdAt: 'desc' },
},
},
}),
this.prisma.ticket.count({ where }),
]);
return {
success: true,
data: tickets,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
}
/**
* 6. Admin: Update Ticket Status / Priority
*/
async updateTicketStatus(ticketId: string, dto: AdminUpdateTicketDto) {
const ticket = await this.prisma.ticket.update({
where: { id: ticketId },
data: {
...(dto.status ? { status: dto.status } : {}),
...(dto.priority ? { priority: dto.priority } : {}),
},
});
return {
success: true,
message: 'وضعیت تیکت بروزرسانی شد',
data: ticket,
};
}
}

View File

@ -28,6 +28,7 @@ const menuGroups = [
items: [
{ icon: Users, label: 'کاربران', path: '/users' },
{ icon: Heart, label: 'حیوانات (Pets)', path: '/pets' },
{ icon: MessageSquare, label: 'تیکت‌ها & مشاوره دامپزشک', path: '/tickets' },
{ icon: Building2, label: 'مدیریت B2B & عمده', path: '/b2b' },
{ icon: PhoneCall, label: 'تماس با ما & اطلاعات', path: '/contact' },
]

View File

@ -36,6 +36,18 @@ export interface OrderItem {
};
}
export interface PaymentTx {
id: string;
trackId?: string;
refNumber?: string;
cardNumber?: string;
amount?: number;
status: string;
message?: string;
gateway?: string;
createdAt: string;
}
export interface Order {
id: string;
trackingNumber?: string;
@ -48,17 +60,21 @@ export interface Order {
isRefill?: boolean;
paymentMethod?: string;
address?: string;
shippingAddress?: string;
orderItems?: OrderItem[];
items?: OrderItem[];
paymentTransactions?: PaymentTx[];
user?: {
firstName?: string;
lastName?: string;
mobile?: string;
phone?: string;
email?: string;
};
}
const statusStyles: Record<string, { label: string; color: string; icon: React.ElementType }> = {
pending_payment: { label: 'در انتظار پرداخت / ناموفق', color: 'bg-rose-100 text-rose-800 border-rose-200', icon: Clock },
pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: Clock },
processing: { label: 'در حال پردازش', color: 'bg-amber-100 text-amber-800 border-amber-200', icon: Clock },
shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Truck },
@ -329,7 +345,7 @@ export default function Orders() {
<ShoppingCart className="w-6 h-6 text-purple-600" />
مدیریت سفارشات
</h2>
<p className="text-gray-500 font-medium mt-1">مشاهده فاکتورها، تغییر وضعیت، ثبت کد رهگیری پستی و چاپ فاکتور رسمی</p>
<p className="text-gray-500 font-medium mt-1">مشاهده فاکتورها، سفارشهای در انتظار/ناموفق، تغییر وضعیت و چاپ فاکتور رسمی</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
@ -339,6 +355,7 @@ export default function Orders() {
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
>
<option value="">همه وضعیتها</option>
<option value="pending_payment">در انتظار پرداخت / ناموفق</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
@ -358,6 +375,33 @@ export default function Orders() {
</div>
</div>
{/* Quick Filter Pills */}
<div className="flex flex-wrap gap-2 pt-1">
{[
{ label: 'همه سفارش‌ها', value: '' },
{ label: 'در انتظار پرداخت / ناموفق', value: 'pending_payment', color: 'text-rose-700 bg-rose-50 border-rose-200' },
{ label: 'در حال پردازش', value: 'processing', color: 'text-amber-700 bg-amber-50 border-amber-200' },
{ label: 'ارسال شده', value: 'shipped', color: 'text-blue-700 bg-blue-50 border-blue-200' },
{ label: 'تحویل شده', value: 'delivered', color: 'text-green-700 bg-green-50 border-green-200' },
{ label: 'لغو شده', value: 'cancelled', color: 'text-red-700 bg-red-50 border-red-200' },
].map((tab) => {
const isSelected = status === tab.value;
return (
<button
key={tab.value}
onClick={() => { setStatus(tab.value); setPage(1); }}
className={`px-4 py-2 rounded-xl font-bold text-xs border transition-all cursor-pointer ${
isSelected
? 'bg-purple-600 text-white border-purple-600 shadow-md shadow-purple-600/20'
: `${tab.color || 'bg-white text-gray-700 border-gray-200'} hover:bg-gray-50`
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* Orders Table */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
@ -432,6 +476,7 @@ export default function Orders() {
onChange={(e) => handleUpdateStatus(order.id, e.target.value)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl border outline-none cursor-pointer transition-all ${statusStyles[order.status]?.color || 'bg-gray-100 text-gray-700'}`}
>
<option value="pending_payment">در انتظار پرداخت / ناموفق</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
@ -544,6 +589,7 @@ export default function Orders() {
onChange={(e) => setModalStatus(e.target.value)}
className="w-full bg-white border border-gray-300 text-gray-900 text-sm font-bold rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="pending_payment">در انتظار پرداخت / ناموفق</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
@ -574,6 +620,48 @@ export default function Orders() {
</div>
</div>
{/* Payment Gateway Transactions Log */}
{selectedOrder.paymentTransactions && selectedOrder.paymentTransactions.length > 0 && (
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 space-y-3">
<h4 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
<Clock className="w-4 h-4 text-purple-600" />
لاگ تراکنشهای درگاه پرداخت زیبال / بانکی
</h4>
<div className="space-y-2 text-xs">
{selectedOrder.paymentTransactions.map((tx) => (
<div key={tx.id} className="p-3 bg-white rounded-xl border border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="space-y-1">
<div className="font-mono font-bold text-slate-700">
شناسه رهگیری زیبال (TrackID): <span className="text-purple-600">{tx.trackId || 'ثبت نشده'}</span>
</div>
{tx.refNumber && (
<div className="text-slate-600">
شماره ارجاع شاپرک (RRN): <span className="font-mono font-bold">{tx.refNumber}</span>
</div>
)}
{tx.message && (
<div className="text-slate-500">
پیام درگاه: <span className="font-semibold">{tx.message}</span>
</div>
)}
</div>
<div className="text-left">
<span className={`inline-block px-2.5 py-1 rounded-lg font-bold text-[11px] ${
tx.status === 'VERIFIED'
? 'bg-emerald-100 text-emerald-800'
: tx.status === 'PENDING'
? 'bg-amber-100 text-amber-800'
: 'bg-rose-100 text-rose-800'
}`}>
{tx.status === 'VERIFIED' ? 'پرداخت تایید شده' : tx.status === 'PENDING' ? 'در انتظار پرداخت' : 'پرداخت ناموفق'}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Items Table */}
<div>
<h4 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-4">

View File

@ -0,0 +1,467 @@
import { useState, useEffect, useCallback } from 'react';
import {
MessageSquare,
Search,
Filter,
Stethoscope,
FileText,
User,
Clock,
CheckCircle2,
AlertCircle,
XCircle,
Send,
RefreshCw,
ChevronLeft,
ChevronRight,
ShieldCheck,
Building2,
Calendar,
} from 'lucide-react';
import api from '../services/api';
import { toast } from 'react-hot-toast';
interface TicketMessage {
id: string;
senderId?: string;
senderRole: 'USER' | 'ADMIN' | 'VET_DOCTOR';
senderName: string;
message: string;
attachment?: string;
createdAt: string;
}
interface Ticket {
id: string;
ticketNumber: string;
subject: string;
category: 'VET_CONSULTATION' | 'ORDER_SUPPORT' | 'PRODUCT_INQUIRY' | 'GENERAL';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
status: 'OPEN' | 'IN_PROGRESS' | 'ANSWERED' | 'CLOSED';
createdAt: string;
updatedAt: string;
user: {
id: string;
firstName?: string;
lastName?: string;
mobile: string;
};
pet?: {
id: string;
name: string;
type: string;
breed: string;
};
messages: TicketMessage[];
}
export default function Tickets() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [categoryFilter, setCategoryFilter] = useState<string>('ALL');
const [searchQuery, setSearchQuery] = useState('');
// Detail / Reply Modal
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [fetchingDetail, setFetchingDetail] = useState(false);
const [replyMessage, setReplyMessage] = useState('');
const [replyStatus, setReplyStatus] = useState<string>('ANSWERED');
const [sendingReply, setSendingReply] = useState(false);
const fetchTickets = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, any> = { page, limit: 15 };
if (statusFilter !== 'ALL') params.status = statusFilter;
if (categoryFilter !== 'ALL') params.category = categoryFilter;
if (searchQuery.trim()) params.search = searchQuery.trim();
const res = await api.get('/tickets/admin/all', { params });
setTickets(res.data?.data || []);
setTotal(res.data?.meta?.total || 0);
} catch {
toast.error('خطا در دریافت لیست تیکت‌ها');
} finally {
setLoading(false);
}
}, [page, statusFilter, categoryFilter, searchQuery]);
useEffect(() => {
fetchTickets();
}, [fetchTickets]);
const handleOpenTicket = async (ticketId: string) => {
try {
setFetchingDetail(true);
const res = await api.get(`/tickets/${ticketId}`);
setSelectedTicket(res.data?.data || null);
setReplyStatus(res.data?.data?.status === 'OPEN' ? 'ANSWERED' : res.data?.data?.status || 'ANSWERED');
} catch {
toast.error('خطا در بارگذاری جزئیات تیکت');
} finally {
setFetchingDetail(false);
}
};
const handleSendReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTicket || !replyMessage.trim()) return;
try {
setSendingReply(true);
await api.post(`/tickets/${selectedTicket.id}/messages`, {
message: replyMessage.trim(),
status: replyStatus,
});
toast.success('پاسخ با موفقیت برای کاربر ارسال شد');
setReplyMessage('');
// Reload current ticket
handleOpenTicket(selectedTicket.id);
fetchTickets();
} catch (err: any) {
toast.error(err.response?.data?.message || 'خطا در ارسال پاسخ');
} finally {
setSendingReply(false);
}
};
const handleUpdateStatus = async (status: string) => {
if (!selectedTicket) return;
try {
await api.put(`/tickets/admin/${selectedTicket.id}/status`, { status });
toast.success('وضعیت تیکت تغییر یافت');
setSelectedTicket({ ...selectedTicket, status: status as any });
fetchTickets();
} catch {
toast.error('خطا در تغییر وضعیت تیکت');
}
};
const getCategoryBadge = (cat: string) => {
switch (cat) {
case 'VET_CONSULTATION':
return { label: 'مشاوره دامپزشک', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: Stethoscope };
case 'ORDER_SUPPORT':
return { label: 'پشتیبانی سفارش', bg: 'bg-blue-50 text-blue-700 border-blue-200', icon: FileText };
case 'PRODUCT_INQUIRY':
return { label: 'سوال محصولی / دارویی', bg: 'bg-purple-50 text-purple-700 border-purple-200', icon: Building2 };
default:
return { label: 'عمومی', bg: 'bg-gray-50 text-gray-700 border-gray-200', icon: MessageSquare };
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'OPEN':
return { label: 'در انتظار پاسخ', bg: 'bg-amber-50 text-amber-700 border-amber-200' };
case 'ANSWERED':
return { label: 'پاسخ داده شد', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200' };
case 'IN_PROGRESS':
return { label: 'در حال بررسی', bg: 'bg-blue-50 text-blue-700 border-blue-200' };
case 'CLOSED':
return { label: 'بسته شده', bg: 'bg-gray-100 text-gray-600 border-gray-200' };
default:
return { label: status, bg: 'bg-gray-50 text-gray-600 border-gray-200' };
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<MessageSquare className="w-7 h-7 text-canina-blue" />
پشتیبانی، تیکتینگ و مشاوره دامپزشکی
</h2>
<p className="text-gray-500 font-medium mt-1">
مشاهده و پاسخدهی به تیکتهای پزشکی، مشاورهای و پشتیبانی مشتریان کنینا
</p>
</div>
<button
onClick={() => fetchTickets()}
className="bg-gray-100 hover:bg-gray-200 text-gray-800 font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-2 transition-colors self-start cursor-pointer"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>بروزرسانی لیست</span>
</button>
</div>
{/* Filters & Search */}
<div className="bg-white p-4 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="w-4 h-4 text-gray-400 absolute right-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="جستجو در موضوع، شماره تیکت، نام کاربر یا شماره موبایل..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-4 pr-10 py-2.5 rounded-xl border border-gray-200 text-xs font-medium focus:border-canina-blue outline-none"
/>
</div>
<div className="flex gap-2">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="px-3 py-2.5 rounded-xl border border-gray-200 text-xs font-bold bg-white focus:border-canina-blue outline-none"
>
<option value="ALL">همه وضعیتها</option>
<option value="OPEN">در انتظار پاسخ</option>
<option value="IN_PROGRESS">در حال بررسی</option>
<option value="ANSWERED">پاسخ داده شده</option>
<option value="CLOSED">بسته شده</option>
</select>
<select
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
className="px-3 py-2.5 rounded-xl border border-gray-200 text-xs font-bold bg-white focus:border-canina-blue outline-none"
>
<option value="ALL">همه دستهبندیها</option>
<option value="VET_CONSULTATION">مشاوره دامپزشک</option>
<option value="ORDER_SUPPORT">پشتیبانی سفارش</option>
<option value="PRODUCT_INQUIRY">سوال دارویی / محصولی</option>
<option value="GENERAL">عمومی</option>
</select>
</div>
</div>
</div>
{/* Tickets Table */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right text-xs">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold">
<tr>
<th className="p-4">شماره تیکت</th>
<th className="p-4">کاربر / صاحب پت</th>
<th className="p-4">پت مرتبط</th>
<th className="p-4">موضوع و دستهبندی</th>
<th className="p-4">وضعیت</th>
<th className="p-4">آخرین بروزرسانی</th>
<th className="p-4 text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading && tickets.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-12 text-gray-400 font-medium">
در حال دریافت تیکتها...
</td>
</tr>
) : tickets.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-12 text-gray-400 font-medium">
تیکتی مطابق با فیلترهای انتخابی یافت نشد.
</td>
</tr>
) : (
tickets.map((t) => {
const cat = getCategoryBadge(t.category);
const st = getStatusBadge(t.status);
const userName = `${t.user?.firstName || ''} ${t.user?.lastName || ''}`.trim() || 'بدون نام';
return (
<tr key={t.id} className="hover:bg-gray-50/80 transition-colors">
<td className="p-4 font-mono font-bold text-canina-blue">
{t.ticketNumber}
</td>
<td className="p-4">
<div className="font-bold text-gray-900">{userName}</div>
<div className="text-gray-400 font-mono text-[11px] mt-0.5">{t.user?.mobile}</div>
</td>
<td className="p-4">
{t.pet ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-700 rounded-lg text-[11px] font-bold border border-purple-100">
🐾 {t.pet.name} ({t.pet.breed || t.pet.type})
</span>
) : (
<span className="text-gray-400 font-medium">-</span>
)}
</td>
<td className="p-4">
<div className="font-bold text-gray-900 max-w-xs truncate">{t.subject}</div>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 mt-1 rounded text-[10px] font-bold border ${cat.bg}`}>
<cat.icon className="w-3 h-3" />
{cat.label}
</span>
</td>
<td className="p-4">
<span className={`px-2.5 py-1 rounded-full font-bold text-[11px] border ${st.bg}`}>
{st.label}
</span>
</td>
<td className="p-4 text-gray-500 font-mono text-[11px]">
{new Date(t.updatedAt).toLocaleDateString('fa-IR')}
</td>
<td className="p-4 text-center">
<button
onClick={() => handleOpenTicket(t.id)}
className="px-3 py-1.5 bg-canina-blue text-white rounded-lg font-bold hover:bg-canina-dark transition-colors cursor-pointer"
>
مشاهده و پاسخ
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="p-4 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500 font-medium">
<div>تعداد کل تیکتها: {total}</div>
<div className="flex items-center gap-2">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="p-2 rounded-lg border border-gray-200 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronRight className="w-4 h-4" />
</button>
<span>صفحه {page}</span>
<button
disabled={tickets.length < 15}
onClick={() => setPage(page + 1)}
className="p-2 rounded-lg border border-gray-200 hover:bg-gray-50 disabled:opacity-40"
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Ticket Details & Admin Reply Modal */}
{selectedTicket && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-3xl rounded-3xl p-6 sm:p-8 shadow-2xl space-y-6 max-h-[90vh] flex flex-col" dir="rtl">
{/* Modal Header */}
<div className="flex items-start justify-between pb-4 border-b border-gray-100">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-black text-gray-900">{selectedTicket.subject}</h3>
<span className="px-2.5 py-0.5 bg-canina-blue/10 text-canina-blue font-mono font-bold text-xs rounded-lg">
{selectedTicket.ticketNumber}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 font-medium mt-1">
<span>کاربر: {selectedTicket.user?.firstName} {selectedTicket.user?.lastName} ({selectedTicket.user?.mobile})</span>
{selectedTicket.pet && (
<span className="text-purple-600 font-bold"> 🐾 {selectedTicket.pet.name} ({selectedTicket.pet.breed || selectedTicket.pet.type})</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<select
value={selectedTicket.status}
onChange={(e) => handleUpdateStatus(e.target.value)}
className="px-2.5 py-1.5 border border-gray-200 rounded-xl text-xs font-bold bg-gray-50 focus:border-canina-blue outline-none"
>
<option value="OPEN">در انتظار پاسخ</option>
<option value="IN_PROGRESS">در حال بررسی</option>
<option value="ANSWERED">پاسخ داده شد</option>
<option value="CLOSED">بسته شده</option>
</select>
<button
type="button"
onClick={() => setSelectedTicket(null)}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 flex items-center justify-center font-bold text-sm cursor-pointer"
>
</button>
</div>
</div>
{/* Messages Chat Scroll */}
<div className="flex-1 overflow-y-auto space-y-4 p-2 min-h-[250px]">
{(selectedTicket.messages || []).map((msg) => {
const isAdminOrVet = msg.senderRole === 'ADMIN' || msg.senderRole === 'VET_DOCTOR';
return (
<div
key={msg.id}
className={`p-4 rounded-2xl max-w-[85%] text-xs font-medium leading-relaxed ${
isAdminOrVet
? 'bg-emerald-50 border border-emerald-200 text-emerald-950 mr-auto'
: 'bg-blue-50 border border-blue-200 text-blue-950 ml-auto'
}`}
>
<div className="flex items-center justify-between gap-4 mb-1.5 pb-1 border-b border-black/5">
<span className="font-black text-[11px] flex items-center gap-1.5">
{isAdminOrVet ? (
<>
<Stethoscope className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-emerald-700">{msg.senderName} (تیم پزشکی / ادمین)</span>
</>
) : (
<>
<User className="w-3.5 h-3.5 text-blue-600" />
<span className="text-blue-700">{msg.senderName} (مشتری)</span>
</>
)}
</span>
<span className="text-[10px] text-gray-400 font-normal font-mono">
{new Date(msg.createdAt).toLocaleString('fa-IR')}
</span>
</div>
<p className="whitespace-pre-wrap">{msg.message}</p>
</div>
);
})}
</div>
{/* Admin Response Composer */}
<form onSubmit={handleSendReply} className="pt-3 border-t border-gray-100 space-y-3">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-gray-700">ارسال پاسخ دامپزشک / پشتیبانی:</label>
<div className="flex items-center gap-2">
<span className="text-gray-400">تغییر وضعیت به:</span>
<select
value={replyStatus}
onChange={(e) => setReplyStatus(e.target.value)}
className="px-2 py-1 bg-gray-50 border border-gray-200 rounded-lg text-xs font-bold"
>
<option value="ANSWERED">پاسخ داده شد</option>
<option value="IN_PROGRESS">در حال بررسی</option>
<option value="CLOSED">پاسخ و بستن تیکت</option>
</select>
</div>
</div>
<textarea
required
rows={3}
placeholder="متن پاسخ تخصصی به کاربر را وارد کنید..."
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-medium focus:border-canina-blue outline-none"
/>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setSelectedTicket(null)}
className="px-4 py-2 rounded-xl text-xs font-bold text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer"
>
بستن
</button>
<button
type="submit"
disabled={sendingReply || !replyMessage.trim()}
className="px-6 py-2 bg-canina-blue text-white rounded-xl text-xs font-black hover:bg-canina-dark disabled:opacity-50 transition-all flex items-center gap-2 cursor-pointer shadow-md shadow-canina-blue/20"
>
<Send className="w-3.5 h-3.5" />
<span>{sendingReply ? 'در حال ارسال...' : 'ارسال پاسخ به کاربر'}</span>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -209,6 +209,35 @@ export default function Transactions() {
}
};
const handleManualVerify = async (txId: string) => {
if (!window.confirm('آیا از تایید دستی این تراکنش (افزایش موجودی کیف پول یا تایید سفارش) اطمینان دارید؟')) return;
try {
const res = await api.post(`/payment/admin/manual-verify/${txId}`, {
adminNote: 'تایید دستی فیش واریزی توسط مدیر سیستم',
});
toast.success(res.data?.message || 'تراکنش با موفقیت تایید شد');
fetchTransactions();
fetchStats();
if (selectedTx) setSelectedTx(null);
} catch (e: any) {
toast.error(e.response?.data?.message || 'خطا در تایید دستی تراکنش');
}
};
const handleManualReject = async (txId: string) => {
const reason = window.prompt('لطفاً دلیل رد تراکنش را وارد کنید:', 'فیش واریزی نامعتبر است');
if (reason === null) return;
try {
const res = await api.post(`/payment/admin/manual-reject/${txId}`, { reason });
toast.success(res.data?.message || 'تراکنش رد شد');
fetchTransactions();
fetchStats();
if (selectedTx) setSelectedTx(null);
} catch (e: any) {
toast.error(e.response?.data?.message || 'خطا در رد تراکنش');
}
};
return (
<div className="space-y-6">
{/* Header */}
@ -796,15 +825,38 @@ export default function Transactions() {
)}
</div>
{/* Close Button */}
<div className="flex justify-end">
{/* Modal Actions */}
<div className="flex items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-2">
{selectedTx.status !== 'VERIFIED' && (
<button
type="button"
onClick={() => handleManualVerify(selectedTx.id)}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-black px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 shadow-md shadow-emerald-600/20 transition-all cursor-pointer"
>
<CheckCircle2 className="w-4 h-4" />
<span>تایید دستی و اعمال مالی</span>
</button>
)}
{selectedTx.status === 'PENDING' && (
<button
type="button"
onClick={() => handleManualReject(selectedTx.id)}
className="bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 transition-all cursor-pointer"
>
<XCircle className="w-4 h-4" />
<span>رد کردن تراکنش</span>
</button>
)}
</div>
<button
type="button"
onClick={() => {
setSelectedTx(null);
setLiveInquiryData(null);
setShowRawLogs(false);
}}
className="bg-gray-100 hover:bg-gray-200 text-gray-800 font-bold px-6 py-2.5 rounded-xl text-xs transition-colors"
className="bg-gray-100 hover:bg-gray-200 text-gray-800 font-bold px-6 py-2.5 rounded-xl text-xs transition-colors cursor-pointer"
>
بستن
</button>

View File

@ -36,6 +36,7 @@ const SystemSettingsPage = lazy(() => import('../pages/SystemSettingsPage'));
const SmsSettingsPage = lazy(() => import('../pages/SmsSettingsPage'));
const SslSettingsPage = lazy(() => import('../pages/SslSettingsPage'));
const Transactions = lazy(() => import('../pages/Transactions'));
const Tickets = lazy(() => import('../pages/Tickets'));
export interface AdminRouteConfig {
path: string;
@ -84,6 +85,7 @@ export const router = createBrowserRouter([
{ path: 'b2b/*', element: <B2BManager /> },
{ path: 'wholesale/*', element: <WholesaleApplications /> },
{ path: 'contact/*', element: <ContactSubmissions /> },
{ path: 'tickets/*', element: <Tickets /> },
],
},
],

View File

@ -105,7 +105,9 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
</main>
<Footer onNavigate={handleNavigate} onShopNavigate={navigateToShop} onB2BOpen={() => setB2BPortalOpen(true)} />
{isB2BPortalOpen && <B2BPortal onClose={() => setB2BPortalOpen(false)} />}
{isB2BPortalOpen && useSettingsStore.getState().getBoolean('b2bRegistrationOpen', true) && useSettingsStore.getState().getBoolean('b2b_enabled', true) && (
<B2BPortal onClose={() => setB2BPortalOpen(false)} />
)}
<CartDrawer
isOpen={isCartOpen}
onClose={() => setCartOpen(false)}

View File

@ -17,6 +17,7 @@ import {
EyeOff,
} from "lucide-react";
import { useUserStore } from "../lib/store/userStore";
import { useSettingsStore } from "../lib/store/settingsStore";
import { authService } from "../lib/services/authService";
import { toast } from "sonner";
import { toPersian, toEnglishDigits } from "../lib/utils";
@ -26,6 +27,14 @@ interface AuthModalProps {
onClose: () => void;
}
function extractOtpFromText(text: string): string {
if (!text) return "";
const clean = toEnglishDigits(text);
const match = clean.match(/(\d{5})/);
if (match) return match[1];
return clean.replace(/[^0-9]/g, "").slice(0, 5);
}
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const { fetchProfile } = useUserStore();
const [view, setView] = useState<
@ -59,12 +68,17 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
phoneNumberRef.current = phoneNumber;
}, [phoneNumber]);
// Robust focus retry on view === "otp"
useEffect(() => {
if (view === "otp") {
const timer = setTimeout(() => {
otpInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
const timers = [50, 150, 300, 500].map((delay) =>
setTimeout(() => {
if (otpInputRef.current) {
otpInputRef.current.focus({ preventScroll: true });
}
}, delay),
);
return () => timers.forEach((t) => clearTimeout(t));
}
}, [view]);
@ -98,7 +112,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const triggerVerifyOtp = useCallback(
async (codeToVerify: string) => {
const cleanCode = toEnglishDigits(codeToVerify).replace(/[^0-9]/g, "").slice(0, 5);
const cleanCode = extractOtpFromText(codeToVerify);
const cleanPhone = toEnglishDigits(phoneNumberRef.current).replace(/[^0-9]/g, "");
if (cleanCode.length !== 5 || !cleanPhone) return;
@ -136,7 +150,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
.then((otp) => {
if (!isMounted) return;
if (otp && typeof otp.code === "string") {
const clean = toEnglishDigits(otp.code).replace(/[^0-9]/g, "").slice(0, 5);
const clean = extractOtpFromText(otp.code);
if (clean) {
setOtpCode(clean);
if (otpInputRef.current) {
@ -156,7 +170,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const handleDomAutofill = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target && target.value) {
const clean = toEnglishDigits(target.value).replace(/[^0-9]/g, "").slice(0, 5);
const clean = extractOtpFromText(target.value);
setOtpCode(clean);
if (clean.length === 5) {
triggerVerifyOtp(clean);
@ -180,9 +194,9 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
}, [view, triggerVerifyOtp]);
const handleOtpChange = (val: string) => {
const clean = toEnglishDigits(val).replace(/[^0-9]/g, "").slice(0, 5);
const clean = extractOtpFromText(val);
setOtpCode(clean);
if (otpInputRef.current) {
if (otpInputRef.current && otpInputRef.current.value !== clean) {
otpInputRef.current.value = clean;
}
if (clean.length === 5) {
@ -469,16 +483,18 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
</div>
</div>
<div className="pt-3 border-t border-medical-gray-100">
<button
type="button"
onClick={() => setView("wholesale-request")}
className="w-full py-3 bg-amber-50 text-amber-900 border border-amber-200 rounded-xl text-xs font-black flex items-center justify-center gap-2 hover:bg-amber-100 transition-colors cursor-pointer"
>
<Building2 className="w-4 h-4 text-amber-600" />
درخواست حساب خریدار عمده (پتشاپ / کلینیک)
</button>
</div>
{useSettingsStore.getState().getBoolean('b2bRegistrationOpen', true) && useSettingsStore.getState().getBoolean('b2b_enabled', true) && (
<div className="pt-3 border-t border-medical-gray-100">
<button
type="button"
onClick={() => setView("wholesale-request")}
className="w-full py-3 bg-amber-50 text-amber-900 border border-amber-200 rounded-xl text-xs font-black flex items-center justify-center gap-2 hover:bg-amber-100 transition-colors cursor-pointer"
>
<Building2 className="w-4 h-4 text-amber-600" />
درخواست حساب خریدار عمده (پتشاپ / کلینیک)
</button>
</div>
)}
<button
type="submit"
@ -784,6 +800,11 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
value={otpCode}
onChange={(e) => handleOtpChange(e.target.value)}
onInput={(e) => handleOtpChange((e.target as HTMLInputElement).value)}
onPaste={(e) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text');
handleOtpChange(pasted);
}}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-center text-2xl font-mono tracking-[0.5em]"
dir="ltr"

View File

@ -117,10 +117,12 @@ export default function Footer({
<span>مشاهده و دانلود کاتالوگ آنلاین محصولات</span>
</Link>
</li>
<li onClick={onB2BOpen} className="hover:text-canina-blue hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-blue-200">
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
{getText('footer_link_b2b', "همکاری با کلینیک‌ها و پت‌شاپ‌ها (B2B)")}
</li>
{useSettingsStore.getState().getBoolean('b2bRegistrationOpen', true) && useSettingsStore.getState().getBoolean('b2b_enabled', true) && (
<li onClick={onB2BOpen} className="hover:text-canina-blue hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-blue-200">
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />
{getText('footer_link_b2b', "همکاری با کلینیک‌ها و پت‌شاپ‌ها (B2B)")}
</li>
)}
</ul>
</div>

View File

@ -468,13 +468,15 @@ export default function Header({
<ShieldCheck className="w-4 h-4 text-emerald-600" />
نمادهای اعتماد و مجوزهای رسمی
</Link>
<button
onClick={onB2BOpen}
className="w-full text-right flex items-center gap-2 px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold"
>
<Building2 className="w-4 h-4 text-canina-blue" />
درخواست نمایندگی B2B
</button>
{useSettingsStore.getState().getBoolean('b2bRegistrationOpen', true) && useSettingsStore.getState().getBoolean('b2b_enabled', true) && (
<button
onClick={onB2BOpen}
className="w-full text-right flex items-center gap-2 px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold cursor-pointer"
>
<Building2 className="w-4 h-4 text-canina-blue" />
درخواست نمایندگی B2B
</button>
)}
<Link href="/contact" className="flex items-center gap-2 px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold">
<PhoneCall className="w-4 h-4 text-canina-blue" />
تماس با مرکز پشتیبانی

View File

@ -1,15 +1,17 @@
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, CreditCard, TrendingUp } from "lucide-react";
import { X, CreditCard, TrendingUp, ShieldCheck } from "lucide-react";
import { toPersian, cn } from "../lib/utils";
import { toast } from "sonner";
import { useUserStore } from "../lib/store/userStore";
import { useSettingsStore } from "../lib/store/settingsStore";
import api from "../lib/services/api";
interface TopUpModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (amount: number) => void;
onConfirm?: (amount: number) => void;
}
const PRESET_AMOUNTS = [
@ -18,25 +20,29 @@ const PRESET_AMOUNTS = [
{ label: "۲,۰۰۰,۰۰۰ تومان", value: 2000000 },
];
export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalProps) {
export default function TopUpModal({ isOpen, onClose }: TopUpModalProps) {
const [amount, setAmount] = useState<string>("");
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const isCardToCardEnabled = useSettingsStore((state) =>
state.getBoolean("payment_card_to_card_enabled", false),
);
const handlePresetSelect = (val: number) => {
setSelectedPreset(val);
setAmount(val.toString());
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/,/g, '');
const val = e.target.value.replace(/,/g, "");
if (/^\d*$/.test(val)) {
setAmount(val);
setSelectedPreset(null);
}
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!useUserStore.getState().isLoggedIn) {
toast.error("جهت افزایش موجودی کیف پول لطفاً ابتدا وارد حساب کاربری خود شوید.");
@ -49,15 +55,23 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
}
setLoading(true);
// Simulate gateway connection
setTimeout(() => {
onConfirm(finalAmount);
toast.success(`مبلغ ${toPersian(finalAmount.toLocaleString())} تومان به کیف پول شما اضافه شد.`);
try {
const res = await api.post("/payment/zibal/wallet/initiate", {
amount: finalAmount,
});
if (res.data?.paymentUrl) {
toast.success("در حال انتقال امن به درگاه پرداخت زیبال...");
window.location.href = res.data.paymentUrl;
} else {
toast.error("خطا در ایجاد تراکنش درگاه پرداخت زیبال");
setLoading(false);
}
} catch (err: unknown) {
const gErr = err as { response?: { data?: { message?: string } }; message?: string };
const errMsg = gErr.response?.data?.message || gErr.message || "خطا در برقراری ارتباط با درگاه پرداخت";
toast.error(errMsg);
setLoading(false);
onClose();
setAmount("");
setSelectedPreset(null);
}, 2000);
}
};
return (
@ -80,21 +94,21 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
>
<div className="bg-medical-gray-50 px-8 py-6 border-b border-medical-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white">
<div className="w-10 h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white shadow-md shadow-canina-blue/20">
<TrendingUp className="w-5 h-5" />
</div>
<h3 className="text-xl font-black text-medical-gray-900 italic">شارژ سریع کیف پول</h3>
<h3 className="text-xl font-black text-medical-gray-900 italic">شارژ آنلاین کیف پول</h3>
</div>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-white border border-medical-gray-200 text-medical-gray-400 hover:text-red-500 transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-8">
<div className="space-y-4">
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-3">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یکی از مبالغ پیشنهادی را انتخاب کنید</label>
<div className="flex flex-wrap gap-2">
{PRESET_AMOUNTS.map((preset) => (
@ -103,7 +117,7 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
type="button"
onClick={() => handlePresetSelect(preset.value)}
className={cn(
"px-6 py-3 rounded-2xl text-sm font-black transition-all border",
"px-5 py-3 rounded-2xl text-sm font-black transition-all border cursor-pointer",
selectedPreset === preset.value
? "bg-canina-blue text-white border-canina-blue shadow-lg shadow-canina-blue/20"
: "bg-white text-medical-gray-700 border-medical-gray-100 hover:border-canina-blue"
@ -115,7 +129,7 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
</div>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">یا مبلغ دلخواه خود را وارد کنید (تومان)</label>
<div className="relative">
<input
@ -123,7 +137,7 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
type="text"
value={amount ? parseInt(amount).toLocaleString() : ""}
onChange={handleInputChange}
className="w-full bg-medical-gray-50 border border-medical-gray-100 rounded-3xl py-6 px-8 outline-none font-black text-2xl text-center text-medical-gray-900 transition-all focus:ring-4 focus:ring-canina-blue/10 focus:border-canina-blue italic"
className="w-full bg-medical-gray-50 border border-medical-gray-100 rounded-3xl py-5 px-8 outline-none font-black text-2xl text-center text-medical-gray-900 transition-all focus:ring-4 focus:ring-canina-blue/10 focus:border-canina-blue italic"
placeholder="۰"
/>
{amount && (
@ -132,32 +146,39 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-4 space-y-2 text-right">
<div className="flex items-center gap-2 text-canina-blue font-bold text-xs">
<CreditCard className="w-4 h-4" />
<span>اطلاعات کارت جهت کارتبهکارت و شارژ کیفپول:</span>
{isCardToCardEnabled ? (
<div className="bg-blue-50 border border-blue-200 rounded-2xl p-4 space-y-2 text-right">
<div className="flex items-center gap-2 text-canina-blue font-bold text-xs">
<CreditCard className="w-4 h-4" />
<span>اطلاعات کارت جهت واریز کارتبهکارت:</span>
</div>
<div className="text-[11px] text-gray-700 space-y-1 font-mono dir-ltr bg-white p-2.5 rounded-xl border border-blue-100">
<div>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div>
<div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</strong></div>
<div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسا آقایی</div>
</div>
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.
</p>
</div>
<div className="text-[11px] text-gray-700 space-y-1 font-mono dir-ltr bg-white p-2.5 rounded-xl border border-blue-100">
<div>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div>
<div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</strong></div>
<div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسا آقایی</div>
) : (
<div className="bg-emerald-50 border border-emerald-200 rounded-2xl p-3.5 flex items-center gap-3 text-emerald-800 text-xs font-bold">
<ShieldCheck className="w-5 h-5 text-emerald-600 shrink-0" />
<span>پرداخت امن از طریق درگاه مستقیم بانکی زیبال و اتصال به کلیه کارتهای عضو شتاب</span>
</div>
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
پس از ثبت درخواست شارژ، فیش شما بررسی و موجودی کیف پول به صورت دستی شارژ میشود.
</p>
</div>
)}
<button
type="submit"
disabled={loading || !amount}
className="w-full py-5 bg-medical-gray-900 text-white rounded-[2rem] font-black text-base flex items-center justify-center gap-3 hover:bg-canina-blue transition-all shadow-xl shadow-black/10 disabled:opacity-50 disabled:cursor-not-allowed group"
className="w-full py-5 bg-canina-blue text-white rounded-[2rem] font-black text-base flex items-center justify-center gap-3 hover:bg-indigo-700 transition-all shadow-xl shadow-canina-blue/20 disabled:opacity-50 disabled:cursor-not-allowed group cursor-pointer"
>
{loading ? (
<div className="w-6 h-6 border-4 border-white/20 border-t-white rounded-full animate-spin" />
) : (
<>
<CreditCard className="w-6 h-6 group-hover:scale-110 transition-transform" />
ثبت درخواست واریز و شارژ کیف پول
اتصال به درگاه و شارژ آنلاین
</>
)}
</button>

View File

@ -11,12 +11,15 @@ import OrderDetailsModal from "./OrderDetailsModal";
import AddressModal from "./AddressModal";
import DeleteConfirmModal from "./DeleteConfirmModal";
import TopUpModal from "./TopUpModal";
import { ticketService, Ticket, TicketMessage } from "../lib/services/ticketService";
import { usePetStore } from "../lib/store/usePetStore";
import { useRouter } from 'next/navigation';
export default function UserDashboard() {
const router = useRouter();
const { profile, isLoggedIn, logout, updateProfile, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
const { pets } = usePetStore();
React.useEffect(() => {
if (!isLoggedIn && typeof window !== "undefined") {
@ -29,6 +32,87 @@ export default function UserDashboard() {
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets" | "overview">("profile");
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
// Tickets State
const [tickets, setTickets] = useState<Ticket[]>([]);
const [isLoadingTickets, setIsLoadingTickets] = useState(false);
const [isNewTicketModalOpen, setIsNewTicketModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [newTicketSubject, setNewTicketSubject] = useState("");
const [newTicketMessage, setNewTicketMessage] = useState("");
const [newTicketCategory, setNewTicketCategory] = useState<string>("VET_CONSULTATION");
const [newTicketPriority, setNewTicketPriority] = useState<string>("MEDIUM");
const [newTicketPetId, setNewTicketPetId] = useState<string>("");
const [isSubmittingTicket, setIsSubmittingTicket] = useState(false);
const [replyMessage, setReplyMessage] = useState("");
const [isSendingReply, setIsSendingReply] = useState(false);
const fetchTickets = React.useCallback(async () => {
try {
setIsLoadingTickets(true);
const data = await ticketService.getMyTickets();
setTickets(data);
} catch {
toast.error("خطا در دریافت لیست تیکت‌ها");
} finally {
setIsLoadingTickets(false);
}
}, []);
React.useEffect(() => {
if (activeTab === "tickets") {
fetchTickets();
}
}, [activeTab, fetchTickets]);
const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTicketSubject.trim() || !newTicketMessage.trim()) {
toast.error("لطفاً موضوع و متن پیام را وارد کنید.");
return;
}
try {
setIsSubmittingTicket(true);
await ticketService.createTicket({
subject: newTicketSubject.trim(),
message: newTicketMessage.trim(),
category: newTicketCategory,
priority: newTicketPriority,
petId: newTicketPetId || undefined,
});
toast.success("تیکت شما با موفقیت ثبت شد!");
setIsNewTicketModalOpen(false);
setNewTicketSubject("");
setNewTicketMessage("");
setNewTicketPetId("");
fetchTickets();
} catch (err: unknown) {
const gErr = err as { response?: { data?: { message?: string } }; message?: string };
toast.error(gErr.response?.data?.message || gErr.message || "خطا در ثبت تیکت");
} finally {
setIsSubmittingTicket(false);
}
};
const handleSendReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTicket || !replyMessage.trim()) return;
try {
setIsSendingReply(true);
const newMsg = await ticketService.replyTicket(selectedTicket.id, replyMessage.trim());
setSelectedTicket((prev) =>
prev ? { ...prev, messages: [...prev.messages, newMsg] } : null,
);
setReplyMessage("");
toast.success("پاسخ شما با موفقیت ارسال شد");
fetchTickets();
} catch (err: unknown) {
const gErr = err as { response?: { data?: { message?: string } }; message?: string };
toast.error(gErr.response?.data?.message || gErr.message || "خطا در ارسال پاسخ");
} finally {
setIsSendingReply(false);
}
};
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
React.useEffect(() => {
if (activeTab === "orders") {
@ -692,23 +776,28 @@ export default function UserDashboard() {
</div>
{/* Main Balance Card */}
<div className="bg-gradient-to-br from-canina-blue to-blue-600 rounded-[2rem] sm:rounded-[3rem] p-6 sm:p-12 text-white shadow-2xl shadow-canina-blue/20 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-6 sm:p-12 opacity-10 group-hover:scale-110 transition-transform duration-700 pointer-events-none">
<Wallet className="w-32 h-32 sm:w-48 sm:h-48" />
<div className="bg-gradient-to-br from-canina-blue via-blue-600 to-indigo-700 rounded-[2rem] sm:rounded-[3rem] p-6 sm:p-10 text-white shadow-2xl shadow-canina-blue/20 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-6 sm:p-10 opacity-10 group-hover:scale-110 transition-transform duration-700 pointer-events-none">
<Wallet className="w-32 h-32 sm:w-44 sm:h-44" />
</div>
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6 sm:gap-10">
<div className="text-center md:text-right w-full md:w-auto">
<div className="text-xs font-black uppercase tracking-[0.2em] text-white/70 mb-3 sm:mb-4 flex items-center gap-2 justify-center md:justify-start">
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 sm:gap-8">
<div className="text-center lg:text-right w-full lg:w-auto">
<div className="text-xs font-black uppercase tracking-[0.2em] text-white/80 mb-2 sm:mb-3 flex items-center gap-2 justify-center lg:justify-start">
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-pulse shadow-sm shadow-emerald-400/50" />
موجودی زنده و قابل استفاده
</div>
<div className="flex items-baseline gap-3 sm:gap-4 flex-row-reverse justify-center md:justify-start">
<div className="text-4xl sm:text-6xl font-black italic tracking-tight">{toPersian(profile.walletBalance.toLocaleString())}</div>
<span className="text-base sm:text-xl not-italic font-bold opacity-80 decoration-white/30 underline underline-offset-8">تومان</span>
<div className="flex items-baseline gap-2 sm:gap-3 justify-center lg:justify-start" dir="rtl">
<div className="text-3xl sm:text-4xl lg:text-5xl font-black italic tracking-tight whitespace-nowrap">
{toPersian((profile.walletBalance || 0).toLocaleString())}
</div>
<span className="text-sm sm:text-base font-bold opacity-90 decoration-white/30 underline underline-offset-8">
تومان
</span>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full md:w-auto">
<div className="flex flex-wrap sm:flex-nowrap gap-3 w-full lg:w-auto shrink-0 justify-center">
<button
type="button"
onClick={() => {
if (profile.walletBalance < 50000) {
toast.error("حداقل موجودی برای ثبت درخواست برداشت ۵۰,۰۰۰ تومان است.");
@ -725,16 +814,17 @@ export default function UserDashboard() {
if (!iban) return;
toast.success(`درخواست برداشت ${toPersian(withdrawAmount.toLocaleString())} تومان با موفقیت ثبت شد و پس از بررسی واریز خواهد شد.`);
}}
className="w-full md:w-40 h-12 sm:h-16 bg-white/10 backdrop-blur-md rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 border border-white/20 hover:bg-white/20 transition-all cursor-pointer"
className="flex-1 sm:flex-initial sm:w-36 h-12 sm:h-14 bg-white/10 backdrop-blur-md rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 border border-white/20 hover:bg-white/20 transition-all cursor-pointer whitespace-nowrap"
>
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" />
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
برداشت وجه
</button>
<button
type="button"
onClick={() => setIsTopUpModalOpen(true)}
className="flex-1 md:w-40 h-12 sm:h-16 bg-white text-canina-blue rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 hover:bg-medical-gray-900 hover:text-white transition-all shadow-xl shadow-black/10 group/topup"
className="flex-1 sm:flex-initial sm:w-36 h-12 sm:h-14 bg-white text-canina-blue rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 hover:bg-medical-gray-900 hover:text-white transition-all shadow-xl shadow-black/10 group/topup cursor-pointer whitespace-nowrap"
>
<ArrowUpCircle className="w-4 h-4 sm:w-5 sm:h-5 group-hover:-translate-y-1 transition-transform" />
<ArrowUpCircle className="w-4 h-4 sm:w-5 sm:h-5 group-hover:-translate-y-1 transition-transform shrink-0" />
شارژ آنی
</button>
</div>
@ -795,51 +885,94 @@ export default function UserDashboard() {
)}
{activeTab === "tickets" && (
<div>
<div className="flex items-center justify-between mb-8">
<h3 className="text-2xl font-black text-medical-gray-900 italic">پشتیبانی و مشاوره آنلاین دامپزشک</h3>
<button
onClick={() => toast.info("فرم ارسال تیکت جدید فعال شد")}
className="px-4 py-2 bg-canina-blue text-white rounded-xl text-xs font-black flex items-center gap-1.5 hover:bg-canina-dark transition-all"
>
<Send className="w-3.5 h-3.5" />
ثبت تیکت جدید
</button>
</div>
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-2xl font-black text-medical-gray-900 italic">پشتیبانی و مشاوره آنلاین دامپزشک</h3>
<p className="text-xs text-medical-gray-500 mt-1 font-medium">پاسخگویی تخصصی به سوالات دارویی، مکملها و پیگیری سفارشات توسط تیم پزشکی کنینا</p>
</div>
<button
type="button"
onClick={() => setIsNewTicketModalOpen(true)}
className="px-4 py-2.5 bg-canina-blue text-white rounded-xl text-xs font-black flex items-center gap-1.5 hover:bg-canina-dark transition-all shadow-md shadow-canina-blue/20 cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
ثبت تیکت جدید
</button>
</div>
<div className="space-y-4">
<div className="p-6 bg-medical-gray-50 border border-medical-gray-200 rounded-[2rem] flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-canina-blue/10 text-canina-blue rounded-2xl flex items-center justify-center">
<Stethoscope className="w-6 h-6" />
</div>
<div>
<h4 className="font-black text-medical-gray-900 text-sm">مشاوره دوز مکمل کانینوکال برای سگ ژرمن</h4>
<p className="text-xs text-medical-gray-400 mt-1">شناسه تیکت: TK-۸۴۹۲ پاسخ داده شده توسط دکتر صادقی</p>
</div>
</div>
<span className="px-3 py-1 bg-green-50 text-green-700 text-xs font-black rounded-full border border-green-200">
پاسخ داده شد
</span>
</div>
<div className="p-6 bg-medical-gray-50 border border-medical-gray-200 rounded-[2rem] flex flex-col md:flex-row items-start md:items-center justify-between gap-4 opacity-75">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center">
<FileText className="w-6 h-6" />
</div>
<div>
<h4 className="font-black text-medical-gray-900 text-sm">استعلام اصالت نسخه پزشکی و سفارش فوری</h4>
<p className="text-xs text-medical-gray-400 mt-1">شناسه تیکت: TK-۸۴۱۰ در حال بررسی توسط کارشناس دارویی</p>
</div>
</div>
<span className="px-3 py-1 bg-amber-50 text-amber-700 text-xs font-black rounded-full border border-amber-200">
در حال بررسی
</span>
</div>
</div>
</div>
)}
{isLoadingTickets ? (
<div className="py-20 text-center text-medical-gray-400 font-bold">در حال بارگذاری تیکتها...</div>
) : tickets.length === 0 ? (
<div className="text-center py-20 bg-medical-gray-50 rounded-[2.5rem] border border-dashed border-medical-gray-200">
<MessageSquare className="w-12 h-12 text-medical-gray-300 mx-auto mb-4" />
<h4 className="text-base font-black text-medical-gray-800 mb-1">هنوز تیکتی ثبت نکردهاید</h4>
<p className="text-xs text-medical-gray-400 font-medium max-w-md mx-auto mb-6">
شما میتوانید سوالات پزشکی و تغذیهای حیوان خانگی خود را از دامپزشکان متخصص کنینا بپرسید یا وضعیت سفارشات خود را پیگیری کنید.
</p>
<button
type="button"
onClick={() => setIsNewTicketModalOpen(true)}
className="px-6 py-3 bg-canina-blue text-white rounded-xl text-xs font-black inline-flex items-center gap-2 hover:bg-canina-dark transition-all shadow-md shadow-canina-blue/20 cursor-pointer"
>
<Send className="w-4 h-4" />
ثبت اولین تیکت مشاوره
</button>
</div>
) : (
<div className="space-y-4">
{tickets.map((t) => {
const isVet = t.category === 'VET_CONSULTATION';
return (
<div
key={t.id}
onClick={() => setSelectedTicket(t)}
className="p-5 sm:p-6 bg-medical-gray-50 hover:bg-white border border-medical-gray-200 hover:border-canina-blue/30 rounded-[1.5rem] sm:rounded-[2rem] flex flex-col md:flex-row items-start md:items-center justify-between gap-4 transition-all hover:shadow-lg hover:shadow-canina-blue/5 cursor-pointer group"
>
<div className="flex items-center gap-4">
<div className={cn(
"w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 transition-transform group-hover:scale-105",
isVet ? "bg-emerald-50 text-emerald-600" : "bg-canina-blue/10 text-canina-blue"
)}>
{isVet ? <Stethoscope className="w-6 h-6" /> : <FileText className="w-6 h-6" />}
</div>
<div>
<div className="flex items-center gap-2 flex-wrap mb-1">
<h4 className="font-black text-medical-gray-900 text-sm group-hover:text-canina-blue transition-colors">
{t.subject}
</h4>
{t.pet && (
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 text-[10px] font-bold rounded-lg border border-purple-100">
🐾 {t.pet.name} ({t.pet.breed || t.pet.type})
</span>
)}
</div>
<p className="text-[11px] text-medical-gray-400 font-medium">
شناسه: {t.ticketNumber} {t.messages?.length || 1} پیام {new Date(t.updatedAt).toLocaleDateString('fa-IR')}
</p>
</div>
</div>
<div className="flex items-center gap-3 self-end md:self-center">
<span className={cn(
"px-3 py-1 text-xs font-black rounded-full border",
t.status === 'ANSWERED' && "bg-emerald-50 text-emerald-700 border-emerald-200",
t.status === 'OPEN' && "bg-amber-50 text-amber-700 border-amber-200",
t.status === 'IN_PROGRESS' && "bg-blue-50 text-blue-700 border-blue-200",
t.status === 'CLOSED' && "bg-gray-100 text-gray-600 border-gray-200",
)}>
{t.status === 'ANSWERED' ? 'پاسخ داده شد' :
t.status === 'OPEN' ? 'در انتظار پاسخ' :
t.status === 'IN_PROGRESS' ? 'در حال بررسی' : 'بسته شده'}
</span>
<ChevronRight className="w-4 h-4 text-medical-gray-300 group-hover:translate-x-[-2px] transition-transform" />
</div>
</div>
);
})}
</div>
)}
</div>
)}
</motion.div>
</div>
@ -858,7 +991,7 @@ export default function UserDashboard() {
<button
onClick={() => { setActiveTab("wallet"); setIsTopUpModalOpen(true); }}
disabled={isTopUpModalOpen}
className="mt-5 sm:mt-8 bg-canina-blue text-white w-full py-3.5 sm:py-4 px-4 rounded-2xl font-black hover:bg-white hover:text-canina-blue transition-all shadow-lg shadow-black/20 flex items-center justify-center gap-2 text-xs sm:text-sm border border-canina-blue"
className="mt-5 sm:mt-8 bg-canina-blue text-white w-full py-3.5 sm:py-4 px-4 rounded-2xl font-black hover:bg-white hover:text-canina-blue transition-all shadow-lg shadow-black/20 flex items-center justify-center gap-2 text-xs sm:text-sm border border-canina-blue cursor-pointer"
>
<ArrowUpCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
<span>شارژ کیف پول</span>
@ -924,6 +1057,201 @@ export default function UserDashboard() {
onClose={() => setIsTopUpModalOpen(false)}
onConfirm={topUpWallet}
/>
{/* New Ticket Modal */}
{isNewTicketModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-lg rounded-3xl p-6 sm:p-8 shadow-2xl space-y-6 max-h-[90vh] overflow-y-auto" dir="rtl">
<div className="flex items-center justify-between pb-4 border-b border-medical-gray-100">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-canina-blue/10 text-canina-blue rounded-xl flex items-center justify-center">
<Stethoscope className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-black text-gray-900">ثبت تیکت و مشاوره دامپزشک</h3>
<p className="text-xs text-gray-400 font-medium">پاسخگویی توسط دامپزشکان و کارشناسان کنینا</p>
</div>
</div>
<button
type="button"
onClick={() => setIsNewTicketModalOpen(false)}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 flex items-center justify-center font-bold text-sm cursor-pointer"
>
</button>
</div>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">موضوع تیکت *</label>
<input
required
type="text"
placeholder="مثال: سوال در مورد دوز مصرف مکمل سگ ژرمن"
value={newTicketSubject}
onChange={(e) => setNewTicketSubject(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium focus:border-canina-blue outline-none"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">دستهبندی موضوع</label>
<select
value={newTicketCategory}
onChange={(e) => setNewTicketCategory(e.target.value)}
className="w-full px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold focus:border-canina-blue outline-none"
>
<option value="VET_CONSULTATION">مشاوره تخصصی دامپزشک</option>
<option value="ORDER_SUPPORT">پیگیری و پشتیبانی سفارش</option>
<option value="PRODUCT_INQUIRY">سوال درباره داروها و مکملها</option>
<option value="GENERAL">عمومی و پیشنهادات</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">حیوان خانگی مرتبط</label>
<select
value={newTicketPetId}
onChange={(e) => setNewTicketPetId(e.target.value)}
className="w-full px-3 py-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold focus:border-canina-blue outline-none"
>
<option value="">بدون انتخاب / عمومی</option>
{pets.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.breed || p.type})
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">شرح پیام یا سوال پزشکی *</label>
<textarea
required
rows={4}
placeholder="سن، وزن، سابقه بیماری، علائم یا سوال خود را با جزئیات کامل بنویسید..."
value={newTicketMessage}
onChange={(e) => setNewTicketMessage(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium focus:border-canina-blue outline-none"
/>
</div>
<div className="pt-2 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsNewTicketModalOpen(false)}
className="px-5 py-2.5 rounded-xl text-xs font-bold text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer"
>
انصراف
</button>
<button
type="submit"
disabled={isSubmittingTicket}
className="px-6 py-2.5 rounded-xl text-xs font-black text-white bg-canina-blue hover:bg-canina-dark disabled:opacity-50 transition-all flex items-center gap-2 cursor-pointer"
>
{isSubmittingTicket ? 'در حال ارسال...' : 'ارسال تیکت'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Ticket Details & Chat Modal */}
{selectedTicket && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-2xl rounded-3xl p-6 sm:p-8 shadow-2xl space-y-6 max-h-[90vh] flex flex-col" dir="rtl">
<div className="flex items-center justify-between pb-4 border-b border-medical-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-canina-blue/10 text-canina-blue rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-black text-gray-900">{selectedTicket.subject}</h3>
<div className="flex items-center gap-2 text-xs text-gray-400 font-medium mt-0.5">
<span>شناسه: {selectedTicket.ticketNumber}</span>
{selectedTicket.pet && (
<span className="text-purple-600 font-bold"> 🐾 {selectedTicket.pet.name}</span>
)}
</div>
</div>
</div>
<button
type="button"
onClick={() => setSelectedTicket(null)}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 flex items-center justify-center font-bold text-sm cursor-pointer"
>
</button>
</div>
{/* Messages Thread */}
<div className="flex-1 overflow-y-auto space-y-4 p-2 min-h-[220px]">
{(selectedTicket.messages || []).map((msg) => {
const isDoctorOrAdmin = msg.senderRole === 'ADMIN' || msg.senderRole === 'VET_DOCTOR';
return (
<div
key={msg.id}
className={cn(
"p-4 rounded-2xl max-w-[85%] text-xs font-medium leading-relaxed",
isDoctorOrAdmin
? "bg-emerald-50 border border-emerald-200 text-emerald-950 ml-auto"
: "bg-blue-50 border border-blue-200 text-blue-950 mr-auto"
)}
>
<div className="flex items-center justify-between gap-4 mb-1.5 pb-1 border-b border-black/5">
<span className="font-black text-[11px] flex items-center gap-1.5">
{isDoctorOrAdmin ? (
<>
<Stethoscope className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-emerald-700">{msg.senderName || 'پاسخ دامپزشک کنینا'}</span>
</>
) : (
<>
<UserCircle className="w-3.5 h-3.5 text-blue-600" />
<span className="text-blue-700">{msg.senderName || 'شما'}</span>
</>
)}
</span>
<span className="text-[10px] text-gray-400 font-normal">
{new Date(msg.createdAt).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="whitespace-pre-wrap">{msg.message}</p>
</div>
);
})}
</div>
{/* Quick Reply Form */}
{selectedTicket.status !== 'CLOSED' ? (
<form onSubmit={handleSendReply} className="pt-3 border-t border-medical-gray-100 flex gap-2">
<input
type="text"
placeholder="پاسخ خود را بنویسید..."
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
className="flex-1 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-medium focus:border-canina-blue outline-none"
/>
<button
type="submit"
disabled={isSendingReply || !replyMessage.trim()}
className="px-5 py-3 bg-canina-blue text-white rounded-xl text-xs font-black hover:bg-canina-dark disabled:opacity-50 transition-all flex items-center gap-1.5 shrink-0 cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>ارسال</span>
</button>
</form>
) : (
<div className="p-3 bg-gray-50 rounded-xl text-center text-xs text-gray-500 font-bold">
این تیکت بسته شده است. در صورت نیاز میتوانید تیکت جدیدی ثبت فرمایید.
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,63 @@
import api from './api';
export interface TicketMessage {
id: string;
ticketId: string;
senderId?: string;
senderRole: 'USER' | 'ADMIN' | 'VET_DOCTOR';
senderName: string;
message: string;
attachment?: string;
createdAt: string;
}
export interface Ticket {
id: string;
ticketNumber: string;
userId: string;
petId?: string;
subject: string;
category: 'VET_CONSULTATION' | 'ORDER_SUPPORT' | 'PRODUCT_INQUIRY' | 'GENERAL';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
status: 'OPEN' | 'ANSWERED' | 'IN_PROGRESS' | 'CLOSED';
createdAt: string;
updatedAt: string;
pet?: {
id: string;
name: string;
type: string;
breed: string;
};
messages: TicketMessage[];
}
export interface CreateTicketPayload {
subject: string;
message: string;
category?: string;
priority?: string;
petId?: string;
attachment?: string;
}
export const ticketService = {
async getMyTickets(): Promise<Ticket[]> {
const res = await api.get('/tickets/my-tickets');
return res.data?.data || [];
},
async getTicketDetails(id: string): Promise<Ticket> {
const res = await api.get(`/tickets/${id}`);
return res.data?.data;
},
async createTicket(payload: CreateTicketPayload): Promise<Ticket> {
const res = await api.post('/tickets', payload);
return res.data?.data;
},
async replyTicket(ticketId: string, message: string, attachment?: string): Promise<TicketMessage> {
const res = await api.post(`/tickets/${ticketId}/messages`, { message, attachment });
return res.data?.data;
}
};

View File

@ -16,6 +16,7 @@ interface SettingsStore {
isInitialized: boolean;
fetchSettings: () => Promise<void>;
getText: (key: string, fallback: string) => string;
getBoolean: (key: string, fallback: boolean) => boolean;
}
export const useSettingsStore = create<SettingsStore>()((set, get) => ({
@ -58,5 +59,12 @@ export const useSettingsStore = create<SettingsStore>()((set, get) => ({
return fallback;
}
return value;
},
getBoolean: (key, fallback) => {
const value = get().texts[key];
if (value === undefined || value === null) {
return fallback;
}
return value === 'true' || value === '1';
}
}));