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
All checks were successful
Deploy Canina / deploy (push) Successful in 1m51s
This commit is contained in:
parent
1aa9d6e34d
commit
42707c04cf
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -477,6 +477,9 @@ export class AdminService {
|
||||
},
|
||||
},
|
||||
coupon: true,
|
||||
paymentTransactions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
63
backend/src/tickets/dto/create-ticket.dto.ts
Normal file
63
backend/src/tickets/dto/create-ticket.dto.ts
Normal 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;
|
||||
}
|
||||
32
backend/src/tickets/dto/ticket-query.dto.ts
Normal file
32
backend/src/tickets/dto/ticket-query.dto.ts
Normal 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;
|
||||
}
|
||||
96
backend/src/tickets/tickets.controller.ts
Normal file
96
backend/src/tickets/tickets.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/tickets/tickets.module.ts
Normal file
12
backend/src/tickets/tickets.module.ts
Normal 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 {}
|
||||
276
backend/src/tickets/tickets.service.ts
Normal file
276
backend/src/tickets/tickets.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
@ -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">
|
||||
|
||||
467
frontend/admin-panel/src/pages/Tickets.tsx
Normal file
467
frontend/admin-panel/src/pages/Tickets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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" />
|
||||
تماس با مرکز پشتیبانی
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
63
frontend/application/lib/services/ticketService.ts
Normal file
63
frontend/application/lib/services/ticketService.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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';
|
||||
}
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user