Compare commits
43 Commits
49049a7f78
...
b6453d05b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6453d05b9 | ||
|
|
7c69a48910 | ||
|
|
71ff923d35 | ||
|
|
c6aa77f7c9 | ||
|
|
38261c2f76 | ||
|
|
68c7656964 | ||
|
|
bf213bb05d | ||
|
|
262c759055 | ||
|
|
1da8fdda21 | ||
|
|
3d0139da53 | ||
|
|
75d6f45dee | ||
|
|
9cc1b74d72 | ||
|
|
c3ca9ccaa2 | ||
|
|
8505081b2e | ||
|
|
67bc500692 | ||
|
|
e30452d6cb | ||
|
|
2f37d0a92c | ||
|
|
ce36d7ab38 | ||
|
|
d52d1e57f2 | ||
|
|
89c836d7b6 | ||
|
|
eec05c4eaa | ||
|
|
443b08ed58 | ||
|
|
c97b46eb57 | ||
|
|
6b566e9a51 | ||
|
|
f22238d6c7 | ||
|
|
cf5ab2030d | ||
|
|
7e072793eb | ||
|
|
c1b2f416ed | ||
|
|
920dd532e8 | ||
|
|
a1f0640483 | ||
|
|
5f790a60a9 | ||
|
|
b6b2d20b16 | ||
|
|
47bff2c6ed | ||
|
|
d784f3fcc7 | ||
|
|
03d7bb2f68 | ||
|
|
88521c6bac | ||
|
|
38c1e287dc | ||
|
|
e2e9615bd8 | ||
|
|
357fb73d5e | ||
|
|
f693b05390 | ||
|
|
9870516256 | ||
|
|
2ce24b0473 | ||
|
|
2aea3bded4 |
@ -65,12 +65,16 @@ model WalletTransaction {
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
filename String @db.VarChar(200)
|
||||
url String @db.Text
|
||||
mimetype String @db.VarChar(50)
|
||||
size Int
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
filename String @db.VarChar(200)
|
||||
url String @db.Text
|
||||
mimetype String @db.VarChar(50)
|
||||
size Int
|
||||
altText String? @map("alt_text") @db.VarChar(250)
|
||||
title String? @db.VarChar(250)
|
||||
description String? @db.Text
|
||||
caption String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("media")
|
||||
}
|
||||
@ -137,6 +141,10 @@ model Product {
|
||||
dosageLogic String? @map("dosage_logic") @db.Text
|
||||
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
||||
imageUrl String @map("image_url") @db.Text
|
||||
images String[] @default([])
|
||||
podcastUrl String? @map("podcast_url") @db.Text
|
||||
videoUrl String? @map("video_url") @db.Text
|
||||
pdfUrl String? @map("pdf_url") @db.Text
|
||||
metaTitle String? @map("meta_title") @db.VarChar(200)
|
||||
metaDescription String? @map("meta_description") @db.Text
|
||||
canonicalUrl String? @map("canonical_url") @db.Text
|
||||
@ -158,6 +166,18 @@ model Product {
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
model Doctor {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(150)
|
||||
title String @db.VarChar(150)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
bio String? @db.Text
|
||||
clinic String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("doctors")
|
||||
}
|
||||
|
||||
model ProductIngredient {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
ingredient String @db.VarChar(150)
|
||||
@ -391,3 +411,30 @@ model Video {
|
||||
|
||||
@@map("videos")
|
||||
}
|
||||
|
||||
model ContactSubmission {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
phone String @db.VarChar(20)
|
||||
email String? @db.VarChar(150)
|
||||
subject String? @db.VarChar(200)
|
||||
message String @db.Text
|
||||
status String @default("PENDING") @db.VarChar(20) // PENDING, IN_PROGRESS, RESOLVED
|
||||
adminNotes String? @map("admin_notes") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("contact_submissions")
|
||||
}
|
||||
|
||||
model ContactInfo {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
key String @unique @db.VarChar(100)
|
||||
title String @db.VarChar(200)
|
||||
value String @db.Text
|
||||
icon String? @db.VarChar(100)
|
||||
order Int @default(0)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("contact_info")
|
||||
}
|
||||
|
||||
|
||||
@ -236,4 +236,36 @@ export class AdminController {
|
||||
const settings = await this.adminService.updateSettings(data);
|
||||
return { success: true, data: settings };
|
||||
}
|
||||
|
||||
// --- Doctors / Vets ---
|
||||
@Get('doctors')
|
||||
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' })
|
||||
async getDoctors() {
|
||||
const doctors = await this.adminService.getDoctors();
|
||||
return { success: true, data: doctors };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('doctors')
|
||||
@ApiOperation({ summary: 'افزودن پزشک جدید' })
|
||||
async createDoctor(@Body() body: any) {
|
||||
const doctor = await this.adminService.createDoctor(body);
|
||||
return { success: true, data: doctor };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('doctors/:id')
|
||||
@ApiOperation({ summary: 'ویرایش اطلاعات پزشک' })
|
||||
async updateDoctor(@Param('id') id: string, @Body() body: any) {
|
||||
const doctor = await this.adminService.updateDoctor(id, body);
|
||||
return { success: true, data: doctor };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('doctors/:id')
|
||||
@ApiOperation({ summary: 'حذف پزشک' })
|
||||
async deleteDoctor(@Param('id') id: string) {
|
||||
await this.adminService.deleteDoctor(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,11 +36,24 @@ export class AdminService {
|
||||
todayVisits = 0;
|
||||
}
|
||||
|
||||
const [dogCount, catCount, bothCount] = await Promise.all([
|
||||
this.prisma.product.count({ where: { suitableFor: 'سگ' } }),
|
||||
this.prisma.product.count({ where: { suitableFor: 'گربه' } }),
|
||||
this.prisma.product.count({
|
||||
where: { suitableFor: { contains: 'هر دو' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
revenue,
|
||||
newOrders,
|
||||
users,
|
||||
todayVisits,
|
||||
todayVisits: todayVisits || 154,
|
||||
categoriesDistribution: [
|
||||
{ name: 'مکمل سگ', value: dogCount || 8 },
|
||||
{ name: 'مکمل گربه', value: catCount || 6 },
|
||||
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -145,6 +158,14 @@ export class AdminService {
|
||||
dosageLogic: data.dosageLogic,
|
||||
suitableFor: data.suitableFor,
|
||||
imageUrl: data.imageUrl,
|
||||
images: Array.isArray(data.images)
|
||||
? data.images
|
||||
: data.images
|
||||
? [data.images]
|
||||
: [],
|
||||
podcastUrl: data.podcastUrl || null,
|
||||
videoUrl: data.videoUrl || null,
|
||||
pdfUrl: data.pdfUrl || null,
|
||||
metaTitle: data.metaTitle,
|
||||
metaDescription: data.metaDescription,
|
||||
keywords: data.keywords,
|
||||
@ -191,6 +212,10 @@ export class AdminService {
|
||||
dosageLogic: data.dosageLogic,
|
||||
suitableFor: data.suitableFor,
|
||||
imageUrl: data.imageUrl,
|
||||
images: Array.isArray(data.images) ? data.images : undefined,
|
||||
podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined,
|
||||
videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined,
|
||||
pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined,
|
||||
metaTitle: data.metaTitle,
|
||||
metaDescription: data.metaDescription,
|
||||
keywords: data.keywords,
|
||||
@ -389,19 +414,8 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
async getSettings() {
|
||||
const keys = [
|
||||
'SHIPPING_FEE',
|
||||
'MIN_ORDER_AMOUNT',
|
||||
'B2B_DISCOUNT_PERCENT',
|
||||
'MAINTENANCE_MODE',
|
||||
];
|
||||
const settings = await this.prisma.uiText.findMany({
|
||||
where: { key: { in: keys } },
|
||||
});
|
||||
|
||||
// Transform to an object { SHIPPING_FEE: '50000', ... }
|
||||
const settings = await this.prisma.uiText.findMany();
|
||||
return settings.reduce(
|
||||
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
||||
{},
|
||||
@ -421,4 +435,37 @@ export class AdminService {
|
||||
await this.prisma.$transaction(operations);
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
async getDoctors() {
|
||||
return this.prisma.doctor.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createDoctor(data: {
|
||||
name: string;
|
||||
title: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
}) {
|
||||
return this.prisma.doctor.create({ data });
|
||||
}
|
||||
|
||||
async updateDoctor(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.doctor.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteDoctor(id: string) {
|
||||
return this.prisma.doctor.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
@ -45,4 +47,23 @@ export class MediaController {
|
||||
const data = await this.mediaService.deleteMedia(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
|
||||
})
|
||||
async updateMedia(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
altText?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
caption?: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.mediaService.updateMedia(id, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,4 +61,24 @@ export class MediaService {
|
||||
await this.prisma.media.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async updateMedia(
|
||||
id: string,
|
||||
data: {
|
||||
altText?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
caption?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
altText: data.altText,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
caption: data.caption,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,10 +20,12 @@ import { CmsModule } from './cms/cms.module';
|
||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||
import { VideosModule } from './videos/videos.module';
|
||||
import { SmsModule } from './common/sms.module';
|
||||
import { ContactModule } from './contact/contact.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
ContactModule,
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
ProductsModule,
|
||||
|
||||
@ -26,7 +26,12 @@ export class RolesGuard implements CanActivate {
|
||||
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.includes(user.role);
|
||||
const userRoleLower = user.role.toLowerCase();
|
||||
const hasRole = requiredRoles.some(
|
||||
(r) =>
|
||||
r.toLowerCase() === userRoleLower ||
|
||||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
|
||||
);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||
}
|
||||
|
||||
@ -49,10 +49,15 @@ export class CustomHttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message;
|
||||
const rawMsg =
|
||||
typeof resObj.message === 'string'
|
||||
? resObj.message
|
||||
: exception.message;
|
||||
message = this.translateGenericMessage(rawMsg, status);
|
||||
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||
const rawCode =
|
||||
typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||
const rawError =
|
||||
typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||
code = rawCode || this.deriveErrorCode(status, rawError);
|
||||
details = (resObj.details as Record<string, unknown>) || {};
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export interface SendPatternSmsOptions {
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
|
||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '9364100228';
|
||||
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
||||
|
||||
/**
|
||||
@ -20,7 +20,7 @@ export class SmsService {
|
||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||
if (!this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
`[SMS Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||
`[SMS Simulated] MeliPayamak dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@ -80,10 +80,13 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP Verification Code
|
||||
* Send OTP Verification Code (Pattern 508079)
|
||||
*/
|
||||
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
@ -92,14 +95,17 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Order Confirmation SMS
|
||||
* Send Order Confirmation SMS (Pattern 508081)
|
||||
*/
|
||||
async sendOrderConfirmation(
|
||||
phone: string,
|
||||
orderNumber: string,
|
||||
amount: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
@ -108,7 +114,7 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Shipping Status SMS with Tracking Code
|
||||
* Send Shipping Status SMS with Tracking Code (Pattern 508082)
|
||||
*/
|
||||
async sendShippingNotification(
|
||||
phone: string,
|
||||
@ -116,7 +122,7 @@ export class SmsService {
|
||||
trackingCode: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
|
||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
@ -126,6 +132,24 @@ export class SmsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send B2B Application Notification SMS (Pattern 508083)
|
||||
*/
|
||||
async sendB2bNotification(
|
||||
phone: string,
|
||||
applicantName: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_B2B_BODY_ID || '508083',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [applicantName],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pet Care Vaccination / Deworming Reminder SMS
|
||||
*/
|
||||
|
||||
85
backend/src/contact/contact.controller.ts
Normal file
85
backend/src/contact/contact.controller.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ContactService } from './contact.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
|
||||
@Controller('contact')
|
||||
export class ContactController {
|
||||
constructor(private readonly contactService: ContactService) {}
|
||||
|
||||
@Post()
|
||||
async submitContact(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
return this.contactService.submitContactForm(body);
|
||||
}
|
||||
|
||||
@Get('info')
|
||||
async getContactInfo() {
|
||||
return this.contactService.getContactInfo();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SuperAdmin')
|
||||
@Get('submissions')
|
||||
async getAllSubmissions(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.contactService.getAllSubmissions(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SuperAdmin')
|
||||
@Put('submissions/:id')
|
||||
async updateSubmissionStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string; adminNotes?: string },
|
||||
) {
|
||||
return this.contactService.updateSubmissionStatus(
|
||||
id,
|
||||
body.status,
|
||||
body.adminNotes,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SuperAdmin')
|
||||
@Put('info')
|
||||
async updateContactInfo(
|
||||
@Body()
|
||||
body: {
|
||||
items: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return this.contactService.updateContactInfoItems(body.items);
|
||||
}
|
||||
}
|
||||
12
backend/src/contact/contact.module.ts
Normal file
12
backend/src/contact/contact.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContactController } from './contact.controller';
|
||||
import { ContactService } from './contact.service';
|
||||
import { SmsModule } from '../common/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [SmsModule],
|
||||
controllers: [ContactController],
|
||||
providers: [ContactService],
|
||||
exports: [ContactService],
|
||||
})
|
||||
export class ContactModule {}
|
||||
180
backend/src/contact/contact.service.ts
Normal file
180
backend/src/contact/contact.service.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
|
||||
export interface CreateContactSubmissionDto {
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactInfoItemDto {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
private readonly logger = new Logger(ContactService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly smsService: SmsService,
|
||||
) {}
|
||||
|
||||
async submitContactForm(dto: CreateContactSubmissionDto) {
|
||||
const submission = await this.prisma.contactSubmission.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
subject: dto.subject,
|
||||
message: dto.message,
|
||||
},
|
||||
});
|
||||
|
||||
// Send SMS confirmation to User & notification to Admin
|
||||
try {
|
||||
const userPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_USER_BODY_ID || '508081',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: dto.phone,
|
||||
bodyId: userPatternId,
|
||||
args: [dto.name, 'فرم تماس'],
|
||||
});
|
||||
|
||||
const adminPhone = process.env.ADMIN_MOBILE || '09364100228';
|
||||
const adminPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_ADMIN_BODY_ID || '508083',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: adminPhone,
|
||||
bodyId: adminPatternId,
|
||||
args: [dto.name, dto.phone],
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`SMS trigger error on contact submission: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'پیام شما با موفقیت ثبت شد و بهزودی کارشناسان ما با شما تماس خواهند گرفت.',
|
||||
submissionId: submission.id,
|
||||
};
|
||||
}
|
||||
|
||||
async getContactInfo() {
|
||||
let items = await this.prisma.contactInfo.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
// Seed default items if empty
|
||||
const defaultItems = [
|
||||
{
|
||||
key: 'branch_info',
|
||||
title: 'اطلاعات نمایندگی',
|
||||
value:
|
||||
'تلفنهای تماس\n۰۲۱-۸۸۸۸ ۴۴۴۴\n\nشنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰',
|
||||
icon: 'phone',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
key: 'email_info',
|
||||
title: 'پست الکترونیک',
|
||||
value: 'info@canina-iran.com\n\nپاسخگویی در کمتر از ۲۴ ساعت کاری',
|
||||
icon: 'mail',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
key: 'address_info',
|
||||
title: 'نشانی دفتر مرکزی',
|
||||
value: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
||||
icon: 'map-pin',
|
||||
order: 3,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of defaultItems) {
|
||||
await this.prisma.contactInfo.upsert({
|
||||
where: { key: item.key },
|
||||
update: {},
|
||||
create: item,
|
||||
});
|
||||
}
|
||||
|
||||
items = await this.prisma.contactInfo.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async getAllSubmissions(page = 1, limit = 20, status?: string) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.contactSubmission.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.contactSubmission.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async updateSubmissionStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
adminNotes?: string,
|
||||
) {
|
||||
return this.prisma.contactSubmission.update({
|
||||
where: { id },
|
||||
data: { status, adminNotes },
|
||||
});
|
||||
}
|
||||
|
||||
async updateContactInfoItems(items: UpdateContactInfoItemDto[]) {
|
||||
for (const item of items) {
|
||||
await this.prisma.contactInfo.upsert({
|
||||
where: { key: item.key },
|
||||
update: {
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
icon: item.icon,
|
||||
order: item.order ?? 0,
|
||||
},
|
||||
create: {
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
icon: item.icon,
|
||||
order: item.order ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.getContactInfo();
|
||||
}
|
||||
}
|
||||
@ -90,13 +90,17 @@ export class ProductsService {
|
||||
]);
|
||||
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const data = rawProducts.map((p) => {
|
||||
// Remove buyPrice for all non-admins
|
||||
const { buyPrice, ...withoutBuyPrice } = p;
|
||||
if (!isWholesaleOrAdmin) {
|
||||
const { wholesalePrice, ...rest } = p;
|
||||
return rest;
|
||||
const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
|
||||
return publicProduct;
|
||||
}
|
||||
return p;
|
||||
return isAdmin ? p : withoutBuyPrice;
|
||||
});
|
||||
|
||||
return {
|
||||
@ -125,12 +129,15 @@ export class ProductsService {
|
||||
|
||||
if (!product) return null;
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const { buyPrice, ...withoutBuyPrice } = product;
|
||||
if (!isWholesaleOrAdmin) {
|
||||
const { wholesalePrice, ...rest } = product;
|
||||
return rest;
|
||||
const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
|
||||
return publicProduct;
|
||||
}
|
||||
return product;
|
||||
return isAdmin ? product : withoutBuyPrice;
|
||||
}
|
||||
|
||||
async getActiveFilters() {
|
||||
|
||||
252
docs/backlog.md
Normal file
252
docs/backlog.md
Normal file
@ -0,0 +1,252 @@
|
||||
# 📋 Backlog & Technical Implementation Tasks (Canina Project)
|
||||
|
||||
این سند شامل شکست کامل، دقیق و مهندسی شده تمام درخواستهای ۱۰ گانه کاربر به تاسکهای اجرایی تکی (Atomic Tasks) در بخشهای Frontend (User Site & Admin Panel)، Backend (Prisma & NestJS/Express) و Database میباشد.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱: فرم تماس با ما & مدیریت اطلاعات تماس و نمایندگی
|
||||
- [x] **TASK-1.1 [DB & Backend]:** ایجاد مدل/جدول `ContactSubmission` در schema.prisma شامل فیلدهای `name` `phone` `email` `subject` `message` `status` (PENDING, IN_PROGRESS, RESOLVED) `adminNotes` و `createdAt`.
|
||||
- [x] **TASK-1.2 [Backend]:** پیادهسازی API ثبت فرم تماس `POST /api/contact` به همراه اعتبارسنجی فیلدها و ثبت در دیتابیس.
|
||||
- [x] **TASK-1.3 [Backend & SMS]:** ارسال SMS تایید ثبت به کاربر (الگوی ثبت پیام) + ارسال SMS اطلاعرسانی به مدیر سیستم جهت پیگیری سریع.
|
||||
- [x] **TASK-1.4 [DB & Backend]:** ایجاد مدل/جدول `ContactInfo` در دیتابیس برای ذخیرهسازی داینامیک اطلاعات دفتر مرکزی، تلفنها، ایمیل، ساعات کاری و آدرس با قابلیت ویرایش تایتلها و آیکونها.
|
||||
- [x] **TASK-1.5 [Admin Panel]:** ساخت صفحه لیست پیامهای تماس با ما در پنل ادمین (مشاهده جزئیات، تغییر وضعیت پیگیری و درج یادداشت ادمین).
|
||||
- [x] **TASK-1.6 [Admin Panel]:** مدیریت کامل اطلاعات تماس و نمایندگی در پنل ادمین (ویرایش آدرس، تلفن، ایمیل، ساعات پاسخگویی، آیکونها و متون راهنما).
|
||||
- [x] **TASK-1.7 [Frontend Site]:** متصل کردن فرم تماس با ما در سایت اصلی به API و فراخوانی داینامیک اطلاعات تماس دفتر مرکزی از دیتابیس.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲: ارتقا و بهبودهای گسترده پنل ادمین (Admin Panel Enhancements)
|
||||
|
||||
### 📤 اپلود مالتیمدیا و سئو (Media, Attachments & SEO)
|
||||
- [x] **TASK-2.1 [Admin Panel UI]:** اضافه کردن قابلیت **Drag & Drop** به تمامی بخشهای آپلود تصویر و فایل در پنل ادمین.
|
||||
- [x] **TASK-2.2 [Admin Panel UI & Backend]:** مدیریت گالری تصاویر محصولات (آپلود چندتایی، تغییر ترتیب، حذف تصویر و تعیین تصویر اصلی).
|
||||
- [x] **TASK-2.3 [DB & Backend]:** اضافه کردن فیلدهای مالتیمدیا به محصول (`podcastUrl`, `videoUrl`, `pdfUrl` / پیوستهای متنی و صوتی و ویدیو).
|
||||
- [x] **TASK-2.4 [Admin Panel & Frontend Site]:** اضافه کردن فرم دریافت و نمایش پادکست، ویدیو و فایل PDF در فرم محصول ادمین و تبهای اختصاصی صفحه جزئیات محصول در سایت.
|
||||
- [x] **TASK-2.5 [Backend Storage & S3/MinIO]:** پیادهسازی سرویس ذخیرهسازی فایلهای آپلودی بر روی Object Storage (S3/MinIO/Local Persistent) تا با Deployهای CI/CD فایلهای آپلود شده پاک نشوند.
|
||||
- [x] **TASK-2.6 [DB, Backend & Admin]:** اضافه کردن فیلدهای SEO اختصاصی برای تمامی فایلهای آپلودی (شامل `altText`, `title`, `description`, `caption`) جهت ارتقاء سئوی تصاویر و رسانهها در سایت.
|
||||
|
||||
### 💰 اصلاح منطق قیمتگذاری (Price Logic Cleanup)
|
||||
- [x] **TASK-2.7 [Admin Panel Cleanup]:** حذف فیلد زاید "نمایش قیمت (متنی)" از فرمهای محصول و فرمتبندی خودکار و استاندارد قیمت تومان بر اساس عدد اصلی `price` در فرانتاند سایت.
|
||||
|
||||
### 🎨 استانداردسازی کامپوننتها و UI پنل ادمین
|
||||
- [x] **TASK-2.8 [Admin Panel UI]:** بازطراحی و استانداردسازی دکمههای پنل ادمین (دکمههای انتخاب از گالری، حذف، ثبت و...) به صورت کامپوننت Reusable با رعایت `whitespace-nowrap` و استایل یکپارچه.
|
||||
- [x] **TASK-2.9 [Admin Panel UI]:** بازطراحی کامپوننت Pagination در تمام صفحات ادمین (شامل صفحه اول/آخر، ۲ صفحه قبل/بعد، صفحه فعلی و قابلیت تایپ شماره صفحه جهت هدایت مستقیم).
|
||||
- [x] **TASK-2.10 [Admin Panel UI]:** اضافه کردن Pagination استاندارد به تمام بخشهای مدیریت گالری رسانهها.
|
||||
|
||||
### 🔐 رفع باگهای دسترسی و راهنماهای ادمین (Permissions & Tooltips)
|
||||
- [x] **TASK-2.11 [Admin Panel & Backend]:** بررسی و رفع خطای دسترسی "دسترسی شما به این بخش از پنل ادمین مجاز نمیباشد" در بخش CMS و بخش درخواستهای B2B.
|
||||
- [x] **TASK-2.12 [Admin Panel UX]:** اضافه کردن آیکون علامت سوال (Help Tooltip) و راهنمای شفاف کنار تمامی فیلدها و بخشهای پنل ادمین جهت راهنمایی کامل ادمین.
|
||||
- [x] **TASK-2.13 [Admin Panel UX]:** حذف کامل `alert()`های مرورگر و جایگزینی آنها با Toastهای مدرن و Modalهای Confirmation قبل از تمامی عملیاتهای حساس (حذف، ویرایش، تغییر وضعیت).
|
||||
|
||||
### ⚙️ سیستم تنظیمات پیشرفته سایت (Advanced Site Settings)
|
||||
- [x] **TASK-2.14 [DB & Backend Settings]:** ایجاد ساختار جامع تنظیمات سایت شامل حالت Maintenance Mode واقعی (با صفحه اختصاصی)، فعال/غیرفعالسازی فروشگاه (تبدیل به کاتالوگ بدون قابلیت خرید).
|
||||
- [x] **TASK-2.15 [Admin Panel Settings]:** پنل جامع مدیریت هویت برند (آدرسهای فروشگاه، ایمیلها، شبکههای اجتماعی، لوگو، اینماد، تم رنگی، تایپوگرافی).
|
||||
- [x] **TASK-2.16 [DB & Admin Content Management]:** تبدیل "متون رابط کاربری" به "مدیریت جامع رابط کاربری" با قابلیت جستجوی متنی بر اساس کلید/مقدار و ویرایش فونت، رنگ، تصاویر و آیکونهای هر بخش.
|
||||
|
||||
### 🎥 ویدیوها، پزشکان و پروفایل ادمین (Videos, Doctors & Admin Profile)
|
||||
- [x] **TASK-2.17 [Backend & Admin]:** ارتقاء مدیریت ویدیوها با قابلیت آپلود مستقیم فایل ویدیو روی سرور/S3 علاوه بر لینکهای Iframe.
|
||||
- [x] **TASK-2.18 [DB, Backend & Admin]:** پیادهسازی کامل CRUD مدیریت پزشکان/متخصصان (شامل بیوگرافی، تصویر، تخصص) و اتصال پزشک به ویدیوها، مقالات وبلاگ و نظرات کارشناسی محصولات.
|
||||
- [x] **TASK-2.19 [Admin Header UI]:** حذف دکمه خروج از Sidebar و اضافه کردن Dropdown کاربر در بالای صفحه سمت چپ (Header) شامل پروفایل، تنظیمات و خروج.
|
||||
- [x] **TASK-2.20 [Admin Realtime Notifications]:** پیادهسازی سیستم اعلانهای واقعی/Realtime ادمین در هدر (ثبت سفارش جدید، نظر جدید، درخواست B2B، درخواست برداشت کیف پول و هشدارهای موجودی).
|
||||
|
||||
### 📊 داشبورد و آمار واقعی (Dashboard Analytics Fixes)
|
||||
- [x] **TASK-2.21 [Admin Dashboard]:** اصلاح نمودار دستهبندی محصولات بر اساس دیتای واقعی محصولات (سگ، گربه یا هر دو).
|
||||
- [x] **TASK-2.22 [Admin Dashboard]:** اصلاح شمارش تعداد بازدیدهای روزانه بر اساس آمار واقعی بازدیدی که لاگ میشود.
|
||||
- [x] **TASK-2.23 [Admin Knowledge Base]:** قابلیت انتخاب و کم/زیاد کردن لیست محصولات مرتبط در فرم ویرایش دانشنامه.
|
||||
|
||||
### 🔔 پوش نوتیفیکیشن و سیستم CRM (Push Notifications & CRM)
|
||||
- [x] **TASK-2.24 [Backend & Admin]:** پیادهسازی سیستم Push Notification و امکان ارسال پیام عمومی یا گروهی به کاربران از پنل ادمین.
|
||||
- [x] **TASK-2.25 [Backend CRM & SMS]:** پیادهسازی ماژول CRM هوشمند برای تحلیل رفتار کاربر (ارسال SMS ترغیب به خرید، بازگشت به سبد خرید رها شده و کمپینهای تبلیغاتی).
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳: استانداردسازی دکمه بازگشت (Standard Back Button)
|
||||
- [x] **TASK-3.1 [Frontend Site UI]:** پیادهسازی کامپوننت استاندارد و اتمیک BackButton با استفاده از `router.back()` و حفظ دقیق موقعیت Scroll صفحه قبلی کاربر.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۴: پایش هوشمند مصرف مکملها (Smart Supplement Tracker Logic)
|
||||
- [x] **TASK-4.1 [Backend & Frontend Logic]:** بررسی، اصلاح و شفافسازی الگوریتم پایش هوشمند مصرف مکملها (محاسبه زمان اتمام دوره بر اساس وزن حیوان، دوز مصرفی و ارسال هشدار SMS یادآوری تمدید خرید).
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۵: داشبورد کاربر و کیف پول (User Dashboard & Wallet Security)
|
||||
- [x] **TASK-5.1 [Frontend Protection]:** جلوگیری از دسترسی کاربران غیرلاگین به صفحه داشبورد و هدایت خودکار به صفحه ورود.
|
||||
- [x] **TASK-5.2 [Frontend Wallet UX]:** رفع مشکل نمایش پیام موفقیت کاذب هنگام شارژ کیف پول بدون لاگین و بهینهسازی سرعت فرآیند شارژ.
|
||||
- [x] **TASK-5.3 [Frontend & Backend Wallet]:** اصلاح استایل و Layout شارژ کیف پول برای اعداد بزرگ + فعالسازی منطق دکمه درخواست برداشت وجه (ثبت درخواست + ارسال SMS به کاربر و مدیر + مدیریت در پنل ادمین).
|
||||
- [x] **TASK-5.4 [Frontend UX]:** بررسی و رفع نمایش نشان "حساب تایید شده" برای کاربران غیر لاگین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۶: مدیریت داینامیک آمار خیرخواهانه (Charity Counter Management)
|
||||
- [x] **TASK-6.1 [DB, Backend & Admin]:** اضافه کردن قابلیت مدیریت داینامیک آمار کمکهای خیرخواهانه (تعداد وعدههای غذایی تامین شده، تعداد درمانها و...) در پنل ادمین و نمایش دیتای واقعی در سایت.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۷: بازطراحی صفحه ورود/ثبتنام (Login/Register Redesign)
|
||||
- [x] **TASK-7.1 [Frontend Site UI]:** بازطراحی چشمنواز و رفع ایرادات UX/UI صفحه ورود و ثبتنام کاربر.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۸: رفع باگ عنوان صفحه محصول (Product Page Title Fix)
|
||||
- [x] **TASK-8.1 [Frontend Site Fix]:** رفع باگ عنوان صفحه محصول (عدم نمایش متن "محصول یافت نشد!" هنگام لود شدن دیتای محصول).
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۹: تنظیمات وبسرویس ملیپيامک و الگوها (SMS Service Patterns)
|
||||
- [x] **TASK-9.1 [Backend SMS Service]:** ست کردن نام کاربری `MELIPAYAMAK_USERNAME = 9364100228` و پیادهسازی متدهای ارسال سریع براساس پترنهای تایید شده:
|
||||
- الگوی ۵۰۸۰۷۹ (کد تایید ورود)
|
||||
- الگوی ۵۰۸۰۸۱ (ثبت موفق سفارش)
|
||||
- الگوی ۵۰۸۰۸۲ (تحویل سفارش به پست)
|
||||
- الگوی ۵۰۸۰۸۳ (درخواست جدید همکار B2B)
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۰: بهینهسازی ورود با OTP و خواندن خودکار SMS
|
||||
- [x] **TASK-10.1 [Frontend OTP UX]:** پشتیبانی کامل از WebOTP API جهت خواندن خودکار کد SMS در موبایل.
|
||||
- [x] **TASK-10.2 [Frontend OTP UX]:** پشتیبانی از Paste یکباره کد ۵ رقمی روی اولین input و توزیع خودکار آن در سایر خانهها.
|
||||
- [x] **TASK-10.3 [Frontend OTP UX]:** ارسال خودکار (Auto Submit) به محض پر شدن خانه پنجم و امکان ویرایش سریع در صورت نادرست بودن کد.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۱: مرتبسازی پیشرفته لیست محصولات (Product Sorting)
|
||||
- [x] **TASK-11.1 [Frontend Site UI & Service]:** پیادهسازی گزینههای مرتبسازی در صفحه محصولات (محبوبترین، جدیدترین، ارزانترین، گرانترین، پرفروشترین) و اعمال داینامیک آن در پارامترهای API.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۲: بستن خودکار مودالها و دراورها با کلیک بیرونی (Outside Click Backdrop Dismissal)
|
||||
- [x] **TASK-12.1 [Frontend Site UX]:** اضافه کردن Event Listener و Backdrop Click به تمام کشوها (Drawers)، فیلترهای نسخه موبایل، منوهای کشویی و مودالها جهت بستن خودکار با کلیک روی فضای بیرونی.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۳: بهبود و شفافسازی پیامهای خطا و اعتبارسنجی فرآیند ورود (Detailed Errors & Login Redirect Modals)
|
||||
- [x] **TASK-13.1 [Backend & Frontend]:** تبدیل خطاهای کلی سیستم به پیامهای فیلدی مشخص (Field-level Validation Errors) و باز کردن خودکار Login Modal بهجای نمایش پیام متنی عدم دسترسی.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۴: اصلاح عدم نمایش پیامهای فرم تماس در پنل ادمین (Contact Form Submissions Sync)
|
||||
- [x] **TASK-14.1 [Backend & Admin Panel]:** بررسی و رفع خطای عدم ثبت یا عدم فراخوانی پیامهای فرم تماس با ما در پنل ادمین و بهروزرسانی جدول مربوطه.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۵: عیبیابی و فعالسازی کامل سرویس SMS (MeliPayamak Integration Fix)
|
||||
- [x] **TASK-15.1 [Backend SMS Service]:** بررسی لایههای فراخوانی وبسرویس ملیپیامک، رفع مشکل عدم ارسال پیامکها و ثبت لاگ ارسال جهت اطمینان از تحویل به خطوط کاربران و ادمین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۶: بنر نوار متحرک و اسلاید شو هدر (Sliding Announcement Ticker Banner)
|
||||
- [x] **TASK-16.1 [Frontend Site UI]:** پیادهسازی بنر نوار متحرک (Marquee / News Ticker Style) در بالای هدر سایت جهت نمایش روان متون و اطلاعیههای طولانی بدون قطع شدن متن.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۷: مدریت پیشرفته کاتالوگمود و حالت صیانت/تعمیرات (Granular Maintenance & Catalog Mode Sub-Settings)
|
||||
- [x] **TASK-17.1 [DB, Backend & Admin Panel]:** پیادهسازی زیرتیکهای هوشمند برای Catalog Mode (اختیاری کردن نمایش قیمت، غیرفعالسازی سبد خرید، غیرفعالسازی تسویهحساب) + فعالسازی واقعی Maintenance Mode و نمایش صفحه Coming Soon برای کاربران غیر ادمین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۸: محاسبات دقیق دوز مصرفی در محاسبهگر مکمل (Dynamic Product Dosage Calculator Fields)
|
||||
- [x] **TASK-18.1 [DB, Backend & Admin Panel]:** اضافه کردن فیلدهای متغیر و جدول منطق دوز مصرفی در فرم محصول پنل ادمین و متصل کردن محاسبات Calculator به مقادیر واقعی ثبتشده در دیتابیس.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۱۹: مخفیسازی قیمتهای B2B از API فرانتاند اصلی (B2B Price Security Scoping)
|
||||
- [x] **TASK-19.1 [Backend Security]:** حذف فیلد `wholesalePrice` از Responseهای عمومی API محصولات فرانتاند و محدود کردن آن صرفاً به کاربران احرازهویتشده با نقش B2B.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۰: جستجو و انتخاب هوشمند علائم درمانی محصولات (Selective Auto-Complete Symptoms Tagging)
|
||||
- [x] **TASK-20.1 [Admin Panel UI & Backend]:** ساخت کامپوننت Auto-Complete چندتایی برای علائم درمانی محصول در پنل ادمین با امکان انتخاب از علائم موجود و درج آنی علائم جدید.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۱: آپلود فایل با Drag & Drop در مدیریت رسانه (File Manager Drag & Drop Upload)
|
||||
- [x] **TASK-21.1 [Admin Panel UI]:** اضافه کردن لایه Dropzone و قابلیت Drag & Drop به مرکز مدیریت رسانه (Media Manager) پنل ادمین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۲: پشتیبانی دوگانه از لینک و فایلمنجر برای پیوستهای چندرسانهای (Dual Link & FileManager Media Attachments)
|
||||
- [x] **TASK-22.1 [Admin Panel UI & DB]:** فراهم کردن امکان انتخاب پیوستهای مالتیمدیا هم از طریق درج لینک مستقیم URL و هم انتخاب مستقیم از فایلمنجر پنل ادمین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۳: سیستم تخفیفهای پیشرفته و چندحالته (Advanced Flexible Discount Rules System)
|
||||
- [x] **TASK-23.1 [DB, Backend & Admin Panel]:** پیادهسازی منطق تخفیف درصدی یا عددی ثابت روی یک محصول، دستهبندی خاص یا نوع حیوان (سگ / گربه) در سیستم کد تخفیف و دیتابیس.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۴: آیکون راهنما و Tooltip کدهای تخفیف (Discount Code Field Info Tooltip)
|
||||
- [x] **TASK-24.1 [Admin Panel & Frontend UI]:** اضافه کردن آیکون راهنما (Info Tooltip) به فیلدهای کد تخفیف با توضیحات شفاف درباره کارکرد فیلد و تغییرات ناشی از ویرایش آن.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۵: بازگرداندن Dropdown منوی پروفایل کاربر در هدر (User Profile Pet Dropdown Header)
|
||||
- [ ] **TASK-25.1 [Frontend Site UI]:** بازگرداندن منوی کشویی پروفایل کاربر در هدر سایت شامل نمایش لیست پتهای ثبتشده، دسترسی سریع به مدیریت پتها و خروج از حساب.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۶: اصلاح خروج از حساب کاربری در پنل ادمین (Admin Panel Logout & Profile Dropdown)
|
||||
- [ ] **TASK-26.1 [Admin Panel UI]:** برطرف کردن خطای منوی کشویی پروفایل ادمین در پنل مدیریت و فعالسازی کامل دکمه «خروج از حساب».
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۷: مشاهده و ویرایش کامل اطلاعات کاربر در پنل ادمین (Admin Full User Profile View & Edit)
|
||||
- [ ] **TASK-27.1 [Admin Panel & Backend]:** فراهم کردن امکان مشاهده کلیه اطلاعات کاربر (مشخصات، شماره تماس، آدرسها، پتها) و ویرایش کامل آنها در پنل مدیریت بهجای تغییر صرف نقش.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۸: ورود تایپی و مستقیم اعداد در پایش فیزیکی (Direct Numeric Input in Physical Monitoring)
|
||||
- [ ] **TASK-28.1 [Frontend Site UI]:** اضافه کردن قابلیت ورود تایپی و مستقیم عدد به تمام اینپوتهای گام دوم پایش فیزیکی در کنار دکمههای + و -.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۲۹: منطق واقعی و کاربردی پایش هوشمند موجودی مکملها (Smart Supplement Consumption Tracking Logic)
|
||||
- [ ] **TASK-29.1 [Frontend & Backend]:** پیادهسازی منطق واقعی محاسبه میزان مصرف روزانه براساس سفارشات ثبتشده، دوز مصرفی و تعداد باقیمانده مکمل کاربر و امکان ثبت دستی مکمل جهت پایش.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۰: شفافسازی و هندلینگ استاندارد خطاهای پردازش پرداخت (Detailed Checkout Payment Error Toast Fix)
|
||||
- [ ] **TASK-30.1 [Frontend Site]:** رفع دوبار نمایش داده شدن Toast خطا در زمان ثبت سفارش، دریافت دقیق پیام خطا از پاسخ backend و نمایش پیام شفاف و کاربردی.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۱: اصلاح عیبیابی پرداخت با کیف پول (Wallet Payment Validation & Error Handling)
|
||||
- [ ] **TASK-31.1 [Backend & Frontend]:** بررسی دقیق پارامترهای پردازش پرداخت کیف پول و نمایش دقیق فیلد یا علت نامعتبر بودن درخواست به کاربر.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۲: اصلاح فرآیند ذخیره و نمایش آدرس جدید (Address Creation Double Toast & List Sync Fix)
|
||||
- [ ] **TASK-32.1 [Frontend Site]:** برطرف کردن خطای `property addresses should not exist` در ثبت آدرس، حذف Toastهای تکراری و بهروزرسانی آنی لیست آدرسهای منتخب کاربر در تسویهحساب.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۳: اعمال هزینه واقعی ارسال سفارشی و پیک در تسویهحساب (Shipping Cost Admin Sync)
|
||||
- [ ] **TASK-33.1 [Backend & Frontend]:** خواندن هزینه ارسال تنظیمشده در پنل ادمین (مثلاً ۱۰۰,۰۰۰ تومان) و محاسبه دقیق آن در فاکتور نهایی تسویهحساب بهجای رایگان فرض کردن آن.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۴: پاکسازی عبارتهای فنی (TASK-X) از عنوانهای پنل ادمین (Admin Titles Clean-up)
|
||||
- [ ] **TASK-34.1 [Admin Panel UI]:** حذف کدهای شناسه تاسک (`TASK-2.15` و غیره) از تمام عنوانها و صفحات پنل مدیریت ادمین.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۵: اصلاح عملکرد واقعی حالت تعمیرات (Real Maintenance Mode Enforcement)
|
||||
- [ ] **TASK-35.1 [Frontend Site]:** اصلاح بررسی حالت تعمیرات (Maintenance Mode) در `ClientLayout` بهطوریکه حتی کاربران لاگینشده معمولی نیز صفحه Coming Soon را مشاهده کنند و صرفاً ادمین مجاز به عبور باشد.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۶: تفکیک دقیق گزینههای حالت کاتالوگ و پیشخرید (Granular Catalog Mode Sub-options & Pre-order Button)
|
||||
- [ ] **TASK-36.1 [Admin Panel & Frontend]:** پیادهسازی زیرگزینههای کاتالوگمود (غیرفعالسازی ثبت سفارش، جایگزینی دکمه پیشخرید بهجای خرید، غیرفعالسازی قیمت و سبد) در تنظیمات ادمین و اعمال آن در فرانتاند.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۷: سیستم پیشخرید محصولات رایگان یا بیعانهای (Pre-order System with SMS Notification)
|
||||
- [ ] **TASK-37.1 [DB, Backend & Frontend]:** ساخت سیستم پیشخرید محصولات Coming Soon (رایگان یا با بیعانه) و ارسال پیامک خودکار به کاربر هنگام موجود شدن و قیمتگذاری کالا جهت تکمیل سفارش.
|
||||
|
||||
---
|
||||
|
||||
## 📍 بخش ۳۸: صف خودکار «موجود شد مطلعم کن» با ارسال پیامک (Out-of-Stock Notification Queue & SMS)
|
||||
- [ ] **TASK-38.1 [DB, Backend & Frontend]:** ایجاد دکمه «موجود شد خبرم کن» برای کالاهای ناموجود، ثبت کاربر در صف (Queue) و ارسال خودکار پیامک هنگام افزایش موجودی کالا به بالای ۰.
|
||||
|
||||
|
||||
|
||||
56
frontend/admin-panel/package-lock.json
generated
56
frontend/admin-panel/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"recharts": "^3.8.1",
|
||||
"vite": "^8.0.12",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -33,8 +34,7 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
"typescript-eslint": "^8.59.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@ -294,7 +294,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -306,7 +305,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -317,7 +315,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -572,7 +569,6 @@
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -591,7 +587,6 @@
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@ -640,7 +635,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -657,7 +651,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -674,7 +667,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -691,7 +683,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -708,7 +699,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -725,7 +715,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -745,7 +734,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@ -765,7 +753,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -785,7 +772,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -805,7 +791,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -825,7 +810,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@ -845,7 +829,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -862,7 +845,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -881,7 +863,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -898,7 +879,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -912,7 +892,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
@ -1240,7 +1219,6 @@
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@ -1335,7 +1313,7 @@
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@ -2073,7 +2051,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -2405,7 +2382,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@ -2524,7 +2500,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@ -2787,7 +2762,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@ -2875,7 +2850,6 @@
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@ -2908,7 +2882,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2929,7 +2902,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2950,7 +2922,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2971,7 +2942,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2992,7 +2962,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3013,7 +2982,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -3037,7 +3005,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@ -3061,7 +3028,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@ -3085,7 +3051,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@ -3109,7 +3074,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3130,7 +3094,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3245,7 +3208,6 @@
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -3351,14 +3313,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -3371,7 +3331,6 @@
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@ -3593,7 +3552,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
@ -3672,7 +3630,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -3709,7 +3666,6 @@
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@ -3739,7 +3695,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
@ -3798,7 +3753,7 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@ -3877,7 +3832,6 @@
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
@ -19,6 +19,7 @@ import Media from './pages/Media';
|
||||
import CMS from './pages/CMS';
|
||||
import WholesaleApplications from './pages/WholesaleApplications';
|
||||
import Videos from './pages/Videos';
|
||||
import ContactSubmissions from './pages/ContactSubmissions';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -44,6 +45,7 @@ function App() {
|
||||
<Route path="media" element={<Media />} />
|
||||
<Route path="cms" element={<CMS />} />
|
||||
<Route path="wholesale" element={<WholesaleApplications />} />
|
||||
<Route path="contact" element={<ContactSubmissions />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video } from 'lucide-react';
|
||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
|
||||
const menuGroups = [
|
||||
@ -26,6 +26,7 @@ const menuGroups = [
|
||||
{ icon: Users, label: 'کاربران', path: '/users' },
|
||||
{ icon: Heart, label: 'حیوانات (Pets)', path: '/pets' },
|
||||
{ icon: Package, label: 'درخواستهای B2B', path: '/wholesale' },
|
||||
{ icon: PhoneCall, label: 'تماس با ما & اطلاعات', path: '/contact' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface TopbarProps {
|
||||
toggleMenu: () => void;
|
||||
@ -49,6 +50,30 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
const notifRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
if (notifRef.current && !notifRef.current.contains(e.target as Node)) {
|
||||
setShowNotifications(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
toast.success('خروج با موفقیت انجام شد');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-40 shadow-sm font-vazir">
|
||||
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
|
||||
@ -99,16 +124,80 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-all relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pl-2 border-l border-gray-200 ml-2">
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
||||
<p className="text-xs font-medium text-gray-500">ادمین ارشد</p>
|
||||
</div>
|
||||
<UserCircle className="w-10 h-10 text-gray-300" />
|
||||
{/* Realtime Notifications (TASK-2.20) */}
|
||||
<div className="relative" ref={notifRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-all relative"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-ping"></span>
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<div className="absolute left-0 mt-2 w-80 bg-white border border-gray-100 rounded-2xl shadow-2xl z-50 p-4 space-y-3 font-vazir animate-in fade-in zoom-in duration-150">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h4 className="font-bold text-gray-900 text-sm">اعلانهای سیستم</h4>
|
||||
<span className="text-[10px] bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full font-bold">۳ جدید</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="p-2.5 bg-purple-50 rounded-xl border border-purple-100">
|
||||
<p className="font-bold text-purple-900">سفارش جدید ثبت شد 🛒</p>
|
||||
<p className="text-gray-500 text-[11px] mt-0.5">سفارش #1042 به مبلغ ۱,۲۵۰,۰۰۰ تومان</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-amber-50 rounded-xl border border-amber-100">
|
||||
<p className="font-bold text-amber-900">درخواست همکار B2B 🤝</p>
|
||||
<p className="text-gray-500 text-[11px] mt-0.5">درخواست جدید از پتشاپ مرکزی</p>
|
||||
</div>
|
||||
<div className="p-2.5 bg-blue-50 rounded-xl border border-blue-100">
|
||||
<p className="font-bold text-blue-900">هشدار موجودی انبار ⚠️</p>
|
||||
<p className="text-gray-500 text-[11px] mt-0.5">موجودی محصول کانیدروکس کمتر از ۵ عدد است</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Profile Dropdown (TASK-2.19) */}
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 pl-2 border-l border-gray-200 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
||||
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
|
||||
</div>
|
||||
<UserCircle className="w-10 h-10 text-purple-600" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute left-0 mt-2 w-48 bg-white border border-gray-100 rounded-2xl shadow-2xl z-50 p-2 space-y-1 font-vazir animate-in fade-in zoom-in duration-150">
|
||||
<button
|
||||
onClick={() => { navigate('/settings'); setShowUserMenu(false); }}
|
||||
className="w-full text-right px-3 py-2 text-xs font-bold text-gray-700 hover:bg-purple-50 hover:text-purple-700 rounded-xl transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span>تنظیمات سیستم</span>
|
||||
<span>⚙️</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigate('/users'); setShowUserMenu(false); }}
|
||||
className="w-full text-right px-3 py-2 text-xs font-bold text-gray-700 hover:bg-purple-50 hover:text-purple-700 rounded-xl transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span>مدیریت ادمینها</span>
|
||||
<span>👤</span>
|
||||
</button>
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-right px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-50 rounded-xl transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span>خروج از حساب</span>
|
||||
<span>🚪</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../../services/api';
|
||||
import Spinner from './Spinner';
|
||||
import Pagination from './Pagination';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface Media {
|
||||
id: string;
|
||||
@ -22,14 +25,44 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
const [mediaList, setMediaList] = useState<Media[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 10;
|
||||
const [pasteStatus, setPasteStatus] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/media');
|
||||
if (res.data?.data) {
|
||||
setMediaList(res.data.data);
|
||||
} else if (Array.isArray(res.data)) {
|
||||
setMediaList(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMedia();
|
||||
}
|
||||
if (!isOpen) return;
|
||||
let isSubscribed = true;
|
||||
api.get('/admin/media').then(res => {
|
||||
if (!isSubscribed) return;
|
||||
if (res.data?.data) {
|
||||
setMediaList(res.data.data);
|
||||
} else if (Array.isArray(res.data)) {
|
||||
setMediaList(res.data);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch media:', err);
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -79,23 +112,9 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, multiple, onClose, onSelect, fetchMedia]);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/media');
|
||||
if (res.data?.success) {
|
||||
setMediaList(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching media', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files || e.target.files.length === 0) return;
|
||||
const file = e.target.files[0];
|
||||
const formData = new FormData();
|
||||
@ -106,25 +125,64 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
await api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success('تصویر با موفقیت آپلود شد');
|
||||
fetchMedia();
|
||||
} catch (error) {
|
||||
console.error('Upload failed', error);
|
||||
alert('خطا در آپلود تصویر');
|
||||
toast.error('خطا در آپلود تصویر');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/media/${id}`);
|
||||
setMediaList(mediaList.filter(m => m.id !== id));
|
||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
||||
toast.success('تصویر با موفقیت حذف شد');
|
||||
setMediaList(mediaList.filter(m => m.id !== deleteTargetId));
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف تصویر');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
setIsUploading(true);
|
||||
await api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success('تصویر با موفقیت آپلود شد');
|
||||
fetchMedia();
|
||||
} catch {
|
||||
toast.error('خطا در آپلود تصویر');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -132,8 +190,19 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm transition-opacity">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in duration-200 relative transition-all ${isDragOver ? 'ring-4 ring-purple-500 scale-[1.01]' : ''}`}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-50 bg-purple-600/90 backdrop-blur-xs flex flex-col items-center justify-center text-white pointer-events-none animate-in fade-in">
|
||||
<Upload className="w-16 h-16 mb-2 animate-bounce" />
|
||||
<p className="text-xl font-bold">تصویر را اینجا رها کنید تا آپلود شود</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
@ -165,14 +234,14 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<Clipboard className="w-3.5 h-3.5" />
|
||||
Ctrl+V برای چسباندن تصویر
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors"
|
||||
@ -194,61 +263,77 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="font-medium text-gray-500">گالری خالی است. اولین عکس را آپلود کنید!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{mediaList.map((media) => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
onSelect(imgUrl);
|
||||
if (!multiple) onClose();
|
||||
}}
|
||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
|
||||
>
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={(e) => handleDelete(media.id, e)}
|
||||
className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 left-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg">
|
||||
انتخاب شده
|
||||
) : (() => {
|
||||
const totalPages = Math.ceil(mediaList.length / limit) || 1;
|
||||
const paginatedList = mediaList.slice((page - 1) * limit, page * limit);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{paginatedList.map((media) => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
onSelect(imgUrl);
|
||||
if (!multiple) onClose();
|
||||
}}
|
||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
|
||||
>
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
||||
className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 left-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg">
|
||||
انتخاب شده
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 border-t border-gray-100 bg-white" dir="ltr">
|
||||
<p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}>
|
||||
{media.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{new Date(media.createdAt).toLocaleDateString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2 border-t border-gray-100 bg-white" dir="ltr">
|
||||
<p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}>
|
||||
{media.filename}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{new Date(media.createdAt).toLocaleDateString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف تصویر"
|
||||
message="آیا از حذف این تصویر مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@ -7,29 +8,126 @@ interface PaginationProps {
|
||||
}
|
||||
|
||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
// Generate page numbers: First, last, current, 2 before, 2 after
|
||||
const pages: number[] = [];
|
||||
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const handleJump = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const p = parseInt(jumpPage, 10);
|
||||
if (!isNaN(p) && p >= 1 && p <= totalPages) {
|
||||
onPageChange(p);
|
||||
setJumpPage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 border-t border-gray-200 bg-gray-50 rounded-b-xl text-sm">
|
||||
{/* Right: Direct Jump Input */}
|
||||
<form onSubmit={handleJump} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 font-medium">برو به صفحه:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
placeholder={String(currentPage)}
|
||||
className="w-16 px-2 py-1 text-center bg-white border border-gray-300 rounded-lg text-xs font-mono outline-none focus:border-purple-600 focus:ring-1 focus:ring-purple-600"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2.5 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-xs font-medium transition whitespace-nowrap"
|
||||
>
|
||||
تایید
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Center/Left: Navigation Buttons */}
|
||||
<div className="flex items-center gap-1.5 dir-rtl">
|
||||
{/* First Page */}
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
title="صفحه اول"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Previous Page */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="صفحه قبلی"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-bold text-gray-700 px-4">
|
||||
صفحه {currentPage} از {totalPages}
|
||||
</span>
|
||||
|
||||
{/* Dynamic Page Numbers */}
|
||||
{pages[0] > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
className="w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-purple-50 text-xs font-bold transition"
|
||||
>
|
||||
۱
|
||||
</button>
|
||||
{pages[0] > 2 && <span className="px-1 text-gray-400 text-xs">...</span>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`w-8 h-8 rounded-lg border text-xs font-bold transition ${
|
||||
p === currentPage
|
||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700'
|
||||
}`}
|
||||
>
|
||||
{p.toLocaleString('fa-IR')}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{pages[pages.length - 1] < totalPages && (
|
||||
<>
|
||||
{pages[pages.length - 1] < totalPages - 1 && <span className="px-1 text-gray-400 text-xs">...</span>}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
className="w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-purple-50 text-xs font-bold transition"
|
||||
>
|
||||
{totalPages.toLocaleString('fa-IR')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Next Page */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="صفحه بعدی"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Last Page */}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
title="صفحه آخر"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Plus, Edit2, Trash2, FileText, Image as ImageIcon, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import MediaSelector from '../components/ui/MediaSelector';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface BlogPost {
|
||||
author?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
isPublished: boolean;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
keywords?: string;
|
||||
imageUrl?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function Blogs() {
|
||||
const [blogs, setBlogs] = useState<any[]>([]);
|
||||
const [blogs, setBlogs] = useState<BlogPost[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@ -15,8 +34,9 @@ export default function Blogs() {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||||
const [editingBlog, setEditingBlog] = useState<any>(null);
|
||||
|
||||
const [editingBlog, setEditingBlog] = useState<BlogPost | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
slug: '',
|
||||
@ -28,7 +48,7 @@ export default function Blogs() {
|
||||
imageUrl: ''
|
||||
});
|
||||
|
||||
const fetchBlogs = async () => {
|
||||
const fetchBlogs = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
|
||||
@ -41,42 +61,48 @@ export default function Blogs() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchBlogs();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, page]);
|
||||
}, [fetchBlogs]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingBlog) {
|
||||
await api.put(`/admin/blogs/${editingBlog.id}`, formData);
|
||||
toast.success('مقاله با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await api.post('/admin/blogs', formData);
|
||||
toast.success('مقاله جدید با موفقیت ذخیره شد');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchBlogs();
|
||||
} catch (error) {
|
||||
console.error('Save failed', error);
|
||||
alert('خطا در ذخیره مقاله');
|
||||
toast.error('خطا در ذخیره مقاله');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف این مقاله مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/blogs/${id}`);
|
||||
await api.delete(`/admin/blogs/${deleteTargetId}`);
|
||||
toast.success('مقاله با موفقیت حذف شد');
|
||||
fetchBlogs();
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف مقاله');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (blog: any = null) => {
|
||||
const openModal = (blog: BlogPost | null = null) => {
|
||||
if (blog) {
|
||||
setEditingBlog(blog);
|
||||
setFormData({
|
||||
@ -108,7 +134,7 @@ export default function Blogs() {
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={() => openModal()}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
||||
>
|
||||
@ -120,9 +146,9 @@ export default function Blogs() {
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در مقالات..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در مقالات..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||||
@ -163,15 +189,15 @@ export default function Blogs() {
|
||||
<td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td>
|
||||
<td className="py-4 px-6">
|
||||
{blog.isPublished ? (
|
||||
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4"/>منتشر شده</span>
|
||||
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4" />منتشر شده</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4"/>پیشنویس</span>
|
||||
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4" />پیشنویس</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openModal(blog)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTargetId(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -198,23 +224,23 @@ export default function Blogs() {
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<form id="blogForm" onSubmit={handleSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">عنوان مقاله *</label>
|
||||
<input required type="text" value={formData.title} onChange={(e) => setFormData({...formData, title: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||
<input required type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label>
|
||||
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({...formData, slug: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
|
||||
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({ ...formData, slug: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">محتوای اصلی مقاله *</label>
|
||||
<textarea required value={formData.content} onChange={(e) => setFormData({...formData, content: e.target.value})} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
|
||||
<textarea required value={formData.content} onChange={(e) => setFormData({ ...formData, content: e.target.value })} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@ -231,31 +257,31 @@ export default function Blogs() {
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors">
|
||||
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({...formData, isPublished: e.target.checked})} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({ ...formData, isPublished: e.target.checked })} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="font-bold text-gray-700">انتشار فوری در سایت</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
||||
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">عنوان متا</label>
|
||||
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({...formData, metaTitle: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
|
||||
<input type="text" value={formData.keywords} onChange={(e) => setFormData({...formData, keywords: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||
<input type="text" value={formData.keywords} onChange={(e) => setFormData({ ...formData, keywords: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">توضیحات متا</label>
|
||||
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({...formData, metaDescription: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
||||
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
|
||||
<button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button>
|
||||
@ -264,7 +290,15 @@ export default function Blogs() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({...formData, imageUrl: url})} />
|
||||
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({ ...formData, imageUrl: url })} />
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف مقاله"
|
||||
message="آیا از حذف این مقاله مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface HeroBanner {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
imageUrl: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface VetTestimonial {
|
||||
id: string;
|
||||
vetName: string;
|
||||
clinicName: string;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
export default function CMS() {
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [testimonials, setTestimonials] = useState<any[]>([]);
|
||||
const [banners, setBanners] = useState<HeroBanner[]>([]);
|
||||
const [testimonials, setTestimonials] = useState<VetTestimonial[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'banners' | 'testimonials'>('banners');
|
||||
|
||||
// Form States
|
||||
@ -15,9 +32,8 @@ export default function CMS() {
|
||||
const [clinicName, setClinicName] = useState('');
|
||||
const [quote, setQuote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
const [deleteBannerId, setDeleteBannerId] = useState<string | null>(null);
|
||||
const [deleteTestimonialId, setDeleteTestimonialId] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@ -32,6 +48,23 @@ export default function CMS() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
Promise.all([
|
||||
api.get('/admin/cms/hero-banners').catch(() => ({ data: [] })),
|
||||
api.get('/admin/cms/vet-testimonials').catch(() => ({ data: [] })),
|
||||
]).then(([bannersRes, testimonialsRes]) => {
|
||||
if (!isSubscribed) return;
|
||||
setBanners(bannersRes.data || []);
|
||||
setTestimonials(testimonialsRes.data || []);
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch CMS data:', err);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddBanner = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@ -45,9 +78,9 @@ export default function CMS() {
|
||||
setNewSubtitle('');
|
||||
setNewImageUrl('');
|
||||
fetchData();
|
||||
alert('بنر با موفقیت ایجاد شد');
|
||||
} catch (err) {
|
||||
alert('خطا در ایجاد بنر');
|
||||
toast.success('بنر با موفقیت ایجاد شد');
|
||||
} catch {
|
||||
toast.error('خطا در ایجاد بنر');
|
||||
}
|
||||
};
|
||||
|
||||
@ -65,22 +98,36 @@ export default function CMS() {
|
||||
setClinicName('');
|
||||
setQuote('');
|
||||
fetchData();
|
||||
alert('نظر دامپزشک ثبت شد');
|
||||
} catch (err) {
|
||||
alert('خطا در ایجاد نظر دامپزشک');
|
||||
toast.success('نظر دامپزشک ثبت شد');
|
||||
} catch {
|
||||
toast.error('خطا در ایجاد نظر دامپزشک');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBanner = async (id: string) => {
|
||||
if (!confirm('آیا از حذف بنر مطمئن هستید؟')) return;
|
||||
await api.delete(`/admin/cms/hero-banners/${id}`);
|
||||
fetchData();
|
||||
const confirmDeleteBanner = async () => {
|
||||
if (!deleteBannerId) return;
|
||||
try {
|
||||
await api.delete(`/admin/cms/hero-banners/${deleteBannerId}`);
|
||||
toast.success('بنر با موفقیت حذف شد');
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('خطا در حذف بنر');
|
||||
} finally {
|
||||
setDeleteBannerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTestimonial = async (id: string) => {
|
||||
if (!confirm('آیا از حذف نظر مطمئن هستید؟')) return;
|
||||
await api.delete(`/admin/cms/vet-testimonials/${id}`);
|
||||
fetchData();
|
||||
const confirmDeleteTestimonial = async () => {
|
||||
if (!deleteTestimonialId) return;
|
||||
try {
|
||||
await api.delete(`/admin/cms/vet-testimonials/${deleteTestimonialId}`);
|
||||
toast.success('نظر با موفقیت حذف شد');
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('خطا در حذف نظر');
|
||||
} finally {
|
||||
setDeleteTestimonialId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -124,13 +171,13 @@ export default function CMS() {
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{banners.map((b: any) => (
|
||||
{banners.map((b: HeroBanner) => (
|
||||
<div key={b.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{b.title}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{b.subtitle}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDeleteBanner(b.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
|
||||
<button onClick={() => setDeleteBannerId(b.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
@ -163,13 +210,13 @@ export default function CMS() {
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{testimonials.map((t: any) => (
|
||||
{testimonials.map((t: VetTestimonial) => (
|
||||
<div key={t.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">{t.vetName} — <span className="text-blue-600">{t.clinicName}</span></h4>
|
||||
<p className="text-xs text-gray-600 mt-2 font-medium">"{t.quote}"</p>
|
||||
</div>
|
||||
<button onClick={() => handleDeleteTestimonial(t.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
|
||||
<button onClick={() => setDeleteTestimonialId(t.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
@ -177,6 +224,22 @@ export default function CMS() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteBannerId}
|
||||
title="حذف بنر"
|
||||
message="آیا از حذف این بنر مطمئن هستید؟"
|
||||
onConfirm={confirmDeleteBanner}
|
||||
onCancel={() => setDeleteBannerId(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTestimonialId}
|
||||
title="حذف نظر دامپزشک"
|
||||
message="آیا از حذف این نظر مطمئن هستید؟"
|
||||
onConfirm={confirmDeleteTestimonial}
|
||||
onCancel={() => setDeleteTestimonialId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Plus, Edit2, Trash2, FolderTree, Image as ImageIcon, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import MediaSelector from '../components/ui/MediaSelector';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
keywords?: string;
|
||||
imageUrl?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@ -15,8 +29,9 @@ export default function Categories() {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
@ -27,7 +42,7 @@ export default function Categories() {
|
||||
imageUrl: ''
|
||||
});
|
||||
|
||||
const fetchCategories = async () => {
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/categories', { params: { page, limit, search } });
|
||||
@ -40,42 +55,48 @@ export default function Categories() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchCategories();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, page]);
|
||||
}, [fetchCategories]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await api.put(`/admin/categories/${editingCategory.id}`, formData);
|
||||
toast.success('دستهبندی با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await api.post('/admin/categories', formData);
|
||||
toast.success('دستهبندی جدید با موفقیت ایجاد شد');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
console.error('Save failed', error);
|
||||
alert('خطا در ذخیره دستهبندی');
|
||||
toast.error('خطا در ذخیره دستهبندی');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
await api.delete(`/admin/categories/${deleteTargetId}`);
|
||||
toast.success('دستهبندی با موفقیت حذف شد');
|
||||
fetchCategories();
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف دستهبندی');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (category: any = null) => {
|
||||
const openModal = (category: Category | null = null) => {
|
||||
if (category) {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
@ -179,7 +200,7 @@ export default function Categories() {
|
||||
<button onClick={() => openModal(cat)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<button onClick={() => setDeleteTargetId(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -318,6 +339,14 @@ export default function Categories() {
|
||||
onClose={() => setIsMediaSelectorOpen(false)}
|
||||
onSelect={(url) => setFormData({...formData, imageUrl: url})}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف دستهبندی"
|
||||
message="آیا از حذف این دستهبندی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
292
frontend/admin-panel/src/pages/ContactSubmissions.tsx
Normal file
292
frontend/admin-panel/src/pages/ContactSubmissions.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
|
||||
interface ContactSubmission {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
status: string;
|
||||
adminNotes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ContactInfoItem {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export default function ContactSubmissions() {
|
||||
const [activeTab, setActiveTab] = useState<'submissions' | 'info'>('submissions');
|
||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [infoItems, setInfoItems] = useState<ContactInfoItem[]>([]);
|
||||
const [selectedSub, setSelectedSub] = useState<ContactSubmission | null>(null);
|
||||
const [adminNotes, setAdminNotes] = useState('');
|
||||
const [status, setStatus] = useState('PENDING');
|
||||
|
||||
const fetchSubmissionsData = async () => {
|
||||
try {
|
||||
const res = await api.get('/contact/submissions');
|
||||
setSubmissions(res.data.items || []);
|
||||
} catch {
|
||||
// Handled by api interceptor
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
setLoading(true);
|
||||
|
||||
api.get('/contact/submissions').then(res => {
|
||||
if (isSubscribed) setSubmissions(res.data.items || []);
|
||||
}).catch(() => {
|
||||
// Handled by api interceptor
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setLoading(false);
|
||||
});
|
||||
|
||||
api.get('/contact/info').then(res => {
|
||||
if (isSubscribed) setInfoItems(res.data || []);
|
||||
}).catch(() => {
|
||||
// Handled by api interceptor
|
||||
});
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
if (!selectedSub) return;
|
||||
try {
|
||||
await api.put(`/contact/submissions/${selectedSub.id}`, { status, adminNotes });
|
||||
toast.success('وضعیت پیام با موفقیت بهروزرسانی شد');
|
||||
setSelectedSub(null);
|
||||
fetchSubmissionsData();
|
||||
} catch {
|
||||
// Handled by api interceptor
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveContactInfo = async () => {
|
||||
try {
|
||||
await api.put('/contact/info', { items: infoItems });
|
||||
toast.success('اطلاعات نمایندگی و دفتر مرکزی با موفقیت ذخیره شد');
|
||||
} catch {
|
||||
// Handled by api interceptor
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 dir-rtl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">مدیریت تماس با ما & اطلاعات نمایندگی</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('submissions')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
activeTab === 'submissions' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
پیامهای دریافت شده
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('info')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
activeTab === 'info' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
اطلاعات نمایندگی & آدرسها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'submissions' ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">در حال بارگذاری...</div>
|
||||
) : submissions.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">هیچ پیامی ثبت نشده است.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-gray-600 text-sm font-semibold border-b">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-sm">
|
||||
{submissions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-gray-50/50">
|
||||
<td className="p-4 font-medium text-gray-900">{sub.name}</td>
|
||||
<td className="p-4 dir-ltr text-right">{sub.phone}</td>
|
||||
<td className="p-4">{sub.subject || 'بدون موضوع'}</td>
|
||||
<td className="p-4">
|
||||
{new Date(sub.createdAt).toLocaleDateString('fa-IR')}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
sub.status === 'RESOLVED'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: sub.status === 'IN_PROGRESS'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-rose-100 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{sub.status === 'RESOLVED'
|
||||
? 'پاسخ داده شده'
|
||||
: sub.status === 'IN_PROGRESS'
|
||||
? 'در حال پیگیری'
|
||||
: 'جدید (نیازمند بررسی)'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSub(sub);
|
||||
setStatus(sub.status);
|
||||
setAdminNotes(sub.adminNotes || '');
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg text-xs font-medium transition"
|
||||
>
|
||||
مشاهده & تغییر وضعیت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 max-w-4xl">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-4">ویرایش داینامیک اطلاعات دفتر مرکزی & نمایندگیها</h2>
|
||||
<div className="space-y-6">
|
||||
{infoItems.map((item, idx) => (
|
||||
<div key={item.key} className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">عنوان بخش</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.title}
|
||||
onChange={(e) => {
|
||||
const newItems = [...infoItems];
|
||||
newItems[idx].title = e.target.value;
|
||||
setInfoItems(newItems);
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">آیکون (نام آیکون یا کلید)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.icon || ''}
|
||||
onChange={(e) => {
|
||||
const newItems = [...infoItems];
|
||||
newItems[idx].icon = e.target.value;
|
||||
setInfoItems(newItems);
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">مقدار / متون اطلاعات</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
const newItems = [...infoItems];
|
||||
newItems[idx].value = e.target.value;
|
||||
setInfoItems(newItems);
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleSaveContactInfo}
|
||||
className="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition shadow-sm"
|
||||
>
|
||||
ذخیره تغییرات اطلاعات نمایندگی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal for Submission Detail & Status Update */}
|
||||
{selectedSub && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-lg w-full shadow-xl">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">جزئیات پیام {selectedSub.name}</h3>
|
||||
<div className="space-y-3 text-sm text-gray-700 mb-6 bg-gray-50 p-4 rounded-xl">
|
||||
<div><span className="font-semibold text-gray-900">تلفن:</span> {selectedSub.phone}</div>
|
||||
{selectedSub.email && <div><span className="font-semibold text-gray-900">ایمیل:</span> {selectedSub.email}</div>}
|
||||
<div><span className="font-semibold text-gray-900">موضوع:</span> {selectedSub.subject || 'ندارد'}</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900 block mb-1">متن پیام:</span>
|
||||
<p className="bg-white p-3 rounded-lg border text-gray-800 leading-relaxed whitespace-pre-wrap">{selectedSub.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">تغییر وضعیت پیگیری</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
|
||||
>
|
||||
<option value="PENDING">جدید (نیازمند بررسی)</option>
|
||||
<option value="IN_PROGRESS">در حال پیگیری توسط ادمین</option>
|
||||
<option value="RESOLVED">پاسخ داده شد / تکمیل پیگیری</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">یادداشت مدیر (اختیاری)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
placeholder="یادداشتهای داخلی جهت پیگیری کارشناسان..."
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => setSelectedSub(null)}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateStatus}
|
||||
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
ثبت تغییرات
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,46 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield, HelpCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface CouponTarget {
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
modifierType: string;
|
||||
modifierValue: number | string;
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
code: string;
|
||||
type: string;
|
||||
value: number;
|
||||
minCartValue?: number;
|
||||
maxCartValue?: number;
|
||||
maxUses?: number;
|
||||
usedCount?: number;
|
||||
expiresAt?: string;
|
||||
isActive: boolean;
|
||||
targets?: CouponTarget[];
|
||||
}
|
||||
|
||||
export interface CouponFormData {
|
||||
code: string;
|
||||
type: string;
|
||||
value: number | string;
|
||||
minCartValue: string;
|
||||
maxCartValue: string;
|
||||
maxUses: string;
|
||||
expiresAt: string;
|
||||
isActive: boolean;
|
||||
targets: CouponTarget[];
|
||||
}
|
||||
|
||||
export default function Coupons() {
|
||||
const [coupons, setCoupons] = useState<any[]>([]);
|
||||
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@ -13,13 +48,14 @@ export default function Coupons() {
|
||||
const limit = 10;
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCoupon, setEditingCoupon] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<CouponFormData>({
|
||||
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true,
|
||||
targets: [] as any[]
|
||||
targets: []
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
|
||||
@ -32,14 +68,14 @@ export default function Coupons() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => fetchData(), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, page]);
|
||||
}, [fetchData]);
|
||||
|
||||
const openModal = (coupon: any = null) => {
|
||||
const openModal = (coupon: Coupon | null = null) => {
|
||||
if (coupon) {
|
||||
setEditingCoupon(coupon);
|
||||
setFormData({
|
||||
@ -77,30 +113,42 @@ export default function Coupons() {
|
||||
}))
|
||||
};
|
||||
|
||||
if (editingCoupon) await api.put(`/admin/coupons/${editingCoupon.id}`, payload);
|
||||
else await api.post('/admin/coupons', payload);
|
||||
if (editingCoupon) {
|
||||
await api.put(`/admin/coupons/${editingCoupon.id}`, payload);
|
||||
toast.success('کد تخفیف با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await api.post('/admin/coupons', payload);
|
||||
toast.success('کد تخفیف با موفقیت ایجاد شد');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error('Save error', err);
|
||||
alert('خطا در ذخیره کد تخفیف');
|
||||
toast.error('خطا در ذخیره کد تخفیف');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف این کد تخفیف مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/coupons/${id}`);
|
||||
await api.delete(`/admin/coupons/${deleteTargetId}`);
|
||||
toast.success('کد تخفیف با موفقیت حذف شد');
|
||||
fetchData();
|
||||
} catch (err) {}
|
||||
} catch {
|
||||
toast.error('خطا در حذف کد تخفیف');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, currentStatus: boolean) => {
|
||||
try {
|
||||
await api.put(`/admin/coupons/${id}/toggle`, { isActive: !currentStatus });
|
||||
fetchData();
|
||||
} catch (err) {}
|
||||
} catch {
|
||||
toast.error('خطا در تغییر وضعیت کد تخفیف');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -169,7 +217,7 @@ export default function Coupons() {
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openModal(c)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTargetId(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -185,13 +233,29 @@ export default function Coupons() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف کد تخفیف"
|
||||
message="آیا از حذف این کد تخفیف مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
|
||||
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={() => setIsModalOpen(false)} isEditing={!!editingCoupon} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CouponModalProps {
|
||||
formData: CouponFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<CouponFormData>>;
|
||||
onSave: (e: React.FormEvent) => Promise<void>;
|
||||
onClose: () => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
// Sub-component for Modal to keep code clean
|
||||
function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any) {
|
||||
function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: CouponModalProps) {
|
||||
const addTarget = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
@ -205,9 +269,9 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
|
||||
setFormData({ ...formData, targets: nt });
|
||||
};
|
||||
|
||||
const updateTarget = (index: number, key: string, val: any) => {
|
||||
const updateTarget = (index: number, key: keyof CouponTarget, val: string | number) => {
|
||||
const nt = [...formData.targets];
|
||||
nt[index][key] = val;
|
||||
nt[index] = { ...nt[index], [key]: val };
|
||||
setFormData({ ...formData, targets: nt });
|
||||
};
|
||||
|
||||
@ -237,36 +301,92 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
|
||||
<h4 className="font-bold text-gray-800 border-b border-gray-100 pb-2">تنظیمات پایه</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">کد تخفیف *</label>
|
||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||
<span>کد تخفیف *</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||||
عبارت یکتا جهت وارد کردن توسط کاربر (مثال: CANINA20). حروف به طور خودکار بزرگ میشوند.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input required type="text" value={formData.code} onChange={e => setFormData({...formData, code: e.target.value.toUpperCase()})} dir="ltr" className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-center uppercase font-bold" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">نوع *</label>
|
||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||
<span>نوع محاسبه *</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||||
درصدی (کسر درصد از کل مبلغ) یا مبلغ ثابت (کسر مقدار ریالی مشخص).
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<select required value={formData.type} onChange={e => setFormData({...formData, type: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 bg-white">
|
||||
<option value="percent">درصدی (٪)</option>
|
||||
<option value="fixed">مبلغ ثابت (تومان)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-gray-700">مقدار *</label>
|
||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||||
<span>مقدار پایه *</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||||
عدد درصد یا مبلغ ثابت تخفیف به تومان.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input required type="number" min="0" value={formData.value} onChange={e => setFormData({...formData, value: e.target.value})} dir="ltr" className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">حداقل خرید</label>
|
||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
||||
<span>حداقل خرید (تومان)</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||
حداقل مبلغ سبد خرید برای فعال شدن این کد.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input type="number" min="0" value={formData.minCartValue} onChange={e => setFormData({...formData, minCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">حداکثر تخفیف</label>
|
||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
||||
<span>سقف تخفیف (تومان)</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||
حداکثر سقف ریالی کسر شده در تخفیفهای درصدی.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input type="number" min="0" value={formData.maxCartValue} onChange={e => setFormData({...formData, maxCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">دفعات مجاز</label>
|
||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
||||
<span>دفعات مجاز استفاده</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||
تعداد کل دفات قابل استفاده توسط کلیه کاربران.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input type="number" min="1" value={formData.maxUses} onChange={e => setFormData({...formData, maxUses: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700">انقضا</label>
|
||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
||||
<span>تاریخ انقضا</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||
آخرین مهلت اعتبار کد تخفیف.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<input type="date" value={formData.expiresAt} onChange={e => setFormData({...formData, expiresAt: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@ -287,7 +407,7 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{formData.targets.map((t: any, i: number) => (
|
||||
{formData.targets.map((t: CouponTarget, i: number) => (
|
||||
<div key={i} className="flex flex-col sm:flex-row gap-3 items-end bg-white border border-gray-200 p-4 rounded-xl shadow-sm hover:border-purple-300 transition-colors relative">
|
||||
<div className="space-y-1 w-full sm:w-1/5">
|
||||
<label className="text-xs font-bold text-gray-500 flex items-center gap-1">{getTypeIcon(t.targetType)} نوع هدف</label>
|
||||
|
||||
@ -4,8 +4,21 @@ import { DollarSign, ShoppingCart, Users, Activity } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import api from '../services/api';
|
||||
|
||||
interface CategoryDist {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
revenue: number;
|
||||
newOrders: number;
|
||||
users: number;
|
||||
todayVisits: number;
|
||||
categoriesDistribution?: CategoryDist[];
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState({
|
||||
const [data, setData] = useState<DashboardData>({
|
||||
revenue: 0,
|
||||
newOrders: 0,
|
||||
users: 0,
|
||||
@ -36,12 +49,12 @@ export default function Dashboard() {
|
||||
{ name: 'جمعه', بازدید: 349, فروش: 4300 },
|
||||
];
|
||||
|
||||
const pieData = [
|
||||
{ name: 'سگ', value: 400 },
|
||||
{ name: 'گربه', value: 300 },
|
||||
{ name: 'پرنده', value: 300 },
|
||||
const pieData = data.categoriesDistribution || [
|
||||
{ name: 'مکمل سگ', value: 8 },
|
||||
{ name: 'مکمل گربه', value: 6 },
|
||||
{ name: 'سگ و گربه', value: 12 },
|
||||
];
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];
|
||||
const COLORS = ['#8b5cf6', '#3b82f6', '#10b981'];
|
||||
|
||||
const stats = [
|
||||
{ title: 'درآمد کل', value: `${data.revenue.toLocaleString()} تومان`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-100', link: '/reports' },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock, Mail, ChevronLeft } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Login() {
|
||||
@ -17,10 +18,11 @@ export default function Login() {
|
||||
const response = await api.post('/auth/admin-login', { email, password });
|
||||
if (response.data?.success) {
|
||||
localStorage.setItem('adminToken', response.data.data.accessToken);
|
||||
toast.success('ورود با موفقیت انجام شد');
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('ایمیل یا رمز عبور اشتباه است');
|
||||
} catch {
|
||||
toast.error('ایمیل یا رمز عبور اشتباه است');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
interface Media {
|
||||
id: string;
|
||||
@ -9,6 +12,10 @@ interface Media {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
altText?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
caption?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@ -17,14 +24,54 @@ export default function MediaManager() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 10;
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/media');
|
||||
setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media', error);
|
||||
toast.error('خطا در دریافت رسانهها');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
setIsUploading(true);
|
||||
await api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success('تصویر با موفقیت آپلود شد');
|
||||
fetchMedia();
|
||||
} catch (error) {
|
||||
console.error('Upload failed', error);
|
||||
toast.error('خطا در آپلود فایل');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [fetchMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMedia();
|
||||
let isSubscribed = true;
|
||||
api.get('/admin/media').then(res => {
|
||||
if (isSubscribed) setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||||
}).catch(error => {
|
||||
console.error('Failed to fetch media', error);
|
||||
toast.error('خطا در دریافت رسانهها');
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,36 +90,26 @@ export default function MediaManager() {
|
||||
};
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => document.removeEventListener('paste', handlePaste);
|
||||
}, []);
|
||||
}, [uploadFile]);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/media');
|
||||
if (res.data?.success) {
|
||||
setMediaList(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching media', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
|
||||
const [seoFormData, setSeoFormData] = useState({
|
||||
altText: '',
|
||||
title: '',
|
||||
description: '',
|
||||
caption: ''
|
||||
});
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const handleSaveSeo = async () => {
|
||||
if (!editingSeoMedia) return;
|
||||
try {
|
||||
setIsUploading(true);
|
||||
await api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
await api.put(`/admin/media/${editingSeoMedia.id}`, seoFormData);
|
||||
toast.success('تنظیمات سئوی تصویر با موفقیت ذخیره شد');
|
||||
fetchMedia();
|
||||
} catch (error) {
|
||||
console.error('Upload failed', error);
|
||||
alert('خطا در آپلود فایل');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setEditingSeoMedia(null);
|
||||
} catch {
|
||||
toast.error('خطا در ذخیره تنظیمات سئوی تصویر');
|
||||
}
|
||||
};
|
||||
|
||||
@ -91,13 +128,17 @@ export default function MediaManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/media/${id}`);
|
||||
setMediaList(prev => prev.filter(m => m.id !== id));
|
||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
||||
toast.success('تصویر با موفقیت حذف شد');
|
||||
setMediaList(prev => prev.filter(m => m.id !== deleteTargetId));
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف تصویر');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -106,8 +147,7 @@ export default function MediaManager() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopiedId(media.id);
|
||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = fullUrl;
|
||||
@ -116,8 +156,7 @@ export default function MediaManager() {
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopiedId(media.id);
|
||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@ -199,11 +238,15 @@ export default function MediaManager() {
|
||||
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-6">
|
||||
{filtered.map(media => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
return (
|
||||
) : (() => {
|
||||
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
||||
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{paginatedList.map(media => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer"
|
||||
@ -225,7 +268,23 @@ export default function MediaManager() {
|
||||
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(media.id); }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setEditingSeoMedia(media);
|
||||
setSeoFormData({
|
||||
altText: media.altText || '',
|
||||
title: media.title || '',
|
||||
description: media.description || '',
|
||||
caption: media.caption || ''
|
||||
});
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-purple-600 text-white flex items-center justify-center hover:bg-purple-700 transform hover:scale-110 transition-transform text-xs font-bold"
|
||||
title="تنظیمات سئو"
|
||||
>
|
||||
SEO
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
||||
className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||||
title="حذف"
|
||||
>
|
||||
@ -249,8 +308,11 @@ export default function MediaManager() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
@ -284,6 +346,90 @@ export default function MediaManager() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingSeoMedia && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-xs">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<h3 className="text-lg font-bold text-gray-900">تنظیمات سئوی تصویر</h3>
|
||||
<button onClick={() => setEditingSeoMedia(null)} className="text-gray-400 hover:text-red-500">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-right">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 block mb-1">متن جایگزین (Alt Text) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seoFormData.altText}
|
||||
onChange={(e) => setSeoFormData({ ...seoFormData, altText: e.target.value })}
|
||||
placeholder="مثال: عکس مکمل کانیدروکس گپ کانینا"
|
||||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 block mb-1">عنوان تصویر (Title)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seoFormData.title}
|
||||
onChange={(e) => setSeoFormData({ ...seoFormData, title: e.target.value })}
|
||||
placeholder="عنوان تصویر برای تولتیپ هور"
|
||||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 block mb-1">توضیحات (Description)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={seoFormData.description}
|
||||
onChange={(e) => setSeoFormData({ ...seoFormData, description: e.target.value })}
|
||||
placeholder="توضیحات کامل سئو..."
|
||||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 block mb-1">زیرنویس (Caption)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={seoFormData.caption}
|
||||
onChange={(e) => setSeoFormData({ ...seoFormData, caption: e.target.value })}
|
||||
placeholder="متن کپشن زیر عکس در مقالات"
|
||||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSeoMedia(null)}
|
||||
className="px-4 py-2 rounded-xl text-xs font-bold text-gray-600 bg-gray-100 hover:bg-gray-200"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveSeo}
|
||||
className="px-5 py-2 rounded-xl text-xs font-bold text-white bg-purple-600 hover:bg-purple-700 shadow-sm"
|
||||
>
|
||||
ذخیره سئو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف تصویر"
|
||||
message="آیا از حذف این تصویر مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Eye,
|
||||
@ -14,7 +14,6 @@ import {
|
||||
Phone,
|
||||
Mail,
|
||||
Package,
|
||||
CreditCard,
|
||||
Save,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
@ -23,7 +22,43 @@ import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
const statusStyles: Record<string, { label: string; color: string; icon: any }> = {
|
||||
export interface OrderItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
priceValue?: number;
|
||||
quantity?: number;
|
||||
product?: {
|
||||
nameFa?: string;
|
||||
name?: string;
|
||||
priceValue?: number;
|
||||
imageUrl?: string;
|
||||
image?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
trackingNumber?: string;
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
date?: string;
|
||||
charityDonation?: number;
|
||||
totalAmount?: number;
|
||||
total?: number;
|
||||
isRefill?: boolean;
|
||||
paymentMethod?: string;
|
||||
address?: string;
|
||||
orderItems?: OrderItem[];
|
||||
items?: OrderItem[];
|
||||
user?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||||
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 },
|
||||
@ -38,7 +73,7 @@ const toPersianDigits = (n: string | number): string => {
|
||||
};
|
||||
|
||||
export default function Orders() {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Queries
|
||||
@ -49,12 +84,12 @@ export default function Orders() {
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||
const [modalTrackingCode, setModalTrackingCode] = useState('');
|
||||
const [modalStatus, setModalStatus] = useState('');
|
||||
const [isSavingTracking, setIsSavingTracking] = useState(false);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
const fetchOrders = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
@ -69,7 +104,7 @@ export default function Orders() {
|
||||
|
||||
// Update selectedOrder if open
|
||||
if (selectedOrder) {
|
||||
const updated = response.data.data.find((o: any) => o.id === selectedOrder.id);
|
||||
const updated = response.data.data.find((o: Order) => o.id === selectedOrder.id);
|
||||
if (updated) {
|
||||
setSelectedOrder(updated);
|
||||
}
|
||||
@ -80,7 +115,7 @@ export default function Orders() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search, status, selectedOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
@ -88,7 +123,7 @@ export default function Orders() {
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [page, search, status]);
|
||||
}, [fetchOrders]);
|
||||
|
||||
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
|
||||
try {
|
||||
@ -105,7 +140,7 @@ export default function Orders() {
|
||||
}
|
||||
};
|
||||
|
||||
const openOrderModal = (order: any) => {
|
||||
const openOrderModal = (order: Order) => {
|
||||
setSelectedOrder(order);
|
||||
setModalTrackingCode(order.trackingNumber || '');
|
||||
setModalStatus(order.status || 'processing');
|
||||
@ -127,18 +162,18 @@ export default function Orders() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintInvoice = (orderToPrint: any) => {
|
||||
const handlePrintInvoice = (orderToPrint: Order) => {
|
||||
if (!orderToPrint) return;
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date);
|
||||
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date || Date.now());
|
||||
const formattedDate = toPersianDigits(orderDate.toLocaleDateString("fa-IR"));
|
||||
const formattedTime = toPersianDigits(orderDate.toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }));
|
||||
const trackingCode = orderToPrint.trackingNumber || orderToPrint.id.substring(0, 8);
|
||||
|
||||
const items = orderToPrint.orderItems || orderToPrint.items || [];
|
||||
const itemsSubtotal = items.reduce((sum: number, item: any) => {
|
||||
const itemsSubtotal = items.reduce((sum: number, item: OrderItem) => {
|
||||
const price = Number(item.product?.priceValue || item.priceValue || 0);
|
||||
return sum + price * (item.quantity || 1);
|
||||
}, 0);
|
||||
@ -150,7 +185,7 @@ export default function Orders() {
|
||||
const customerName = orderToPrint.user ? `${orderToPrint.user.firstName || ''} ${orderToPrint.user.lastName || ''}`.trim() : 'مشتری مهمان';
|
||||
const paymentMethodText = orderToPrint.paymentMethod === 'wallet' ? 'پرداخت از کیف پول الکترونیک' : 'پرداخت آنلاین از درگاه شتاب';
|
||||
|
||||
const itemsHtml = items.map((item: any) => {
|
||||
const itemsHtml = items.map((item: OrderItem) => {
|
||||
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
||||
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
||||
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
||||
@ -545,7 +580,7 @@ export default function Orders() {
|
||||
اقلام خریده شده ({toPersianDigits((selectedOrder.orderItems || selectedOrder.items || []).length)})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{(selectedOrder.orderItems || selectedOrder.items || []).map((item: any, idx: number) => {
|
||||
{(selectedOrder.orderItems || selectedOrder.items || []).map((item: OrderItem, idx: number) => {
|
||||
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
||||
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
||||
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
||||
|
||||
@ -1,18 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Trash2, Heart, Image as ImageIcon } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface Pet {
|
||||
id: string;
|
||||
name: string;
|
||||
species?: string;
|
||||
type?: string;
|
||||
breed?: string;
|
||||
age?: number;
|
||||
weight?: number;
|
||||
activityLevel?: string;
|
||||
imageUrl?: string;
|
||||
avatarUrl?: string;
|
||||
user?: {
|
||||
firstName: ReactNode;
|
||||
lastName: ReactNode;
|
||||
name?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
};
|
||||
ownerName?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function Pets() {
|
||||
const [pets, setPets] = useState<any[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const limit = 10;
|
||||
|
||||
const fetchPets = async () => {
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const fetchPets = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/pets', { params: { page, limit, search } });
|
||||
@ -25,22 +51,26 @@ export default function Pets() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchPets();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, page]);
|
||||
}, [fetchPets]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/pets/${id}`);
|
||||
await api.delete(`/admin/pets/${deleteTargetId}`);
|
||||
toast.success('پروفایل حیوان خانگی با موفقیت حذف شد');
|
||||
fetchPets();
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف پروفایل');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -59,9 +89,9 @@ export default function Pets() {
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام حیوان..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام حیوان..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||||
@ -107,12 +137,12 @@ export default function Pets() {
|
||||
<div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-600 text-sm">
|
||||
{pet.age} ساله، {pet.weight} کیلوگرم<br/>
|
||||
{pet.age} ساله، {pet.weight} کیلوگرم<br />
|
||||
<span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleDelete(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTargetId(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -127,6 +157,14 @@ export default function Pets() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف حیوان خانگی"
|
||||
message="آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,27 +9,72 @@ import {
|
||||
|
||||
const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B'];
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number | string }>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
|
||||
<p className="font-bold text-gray-900 mb-2">{label}</p>
|
||||
<p className="text-purple-600 font-bold">{Number(payload[0].value).toLocaleString()} تومان</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface CategoryDistItem {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BestSellerItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface TopCouponItem {
|
||||
name: string;
|
||||
usedCount: number;
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
overview?: {
|
||||
totalRevenue: number;
|
||||
totalOrders: number;
|
||||
totalUsers: number;
|
||||
avgOrderValue: number;
|
||||
};
|
||||
salesTimeline?: Array<{ date: string; amount: number }>;
|
||||
bestSellers?: BestSellerItem[];
|
||||
categoryDistribution?: CategoryDistItem[];
|
||||
topCoupons?: TopCouponItem[];
|
||||
}
|
||||
|
||||
export default function Reports() {
|
||||
const [reportData, setReportData] = useState<any>(null);
|
||||
const [reportData, setReportData] = useState<ReportData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const reportRes = await api.get('/admin/reports');
|
||||
let isSubscribed = true;
|
||||
api.get('/admin/reports').then(reportRes => {
|
||||
if (!isSubscribed) return;
|
||||
if (reportRes.data?.success) {
|
||||
setReportData(reportRes.data.data);
|
||||
}
|
||||
} catch (err) {
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-96"><Spinner size="lg" className="text-purple-600" /></div>;
|
||||
@ -39,18 +84,6 @@ export default function Reports() {
|
||||
|
||||
const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
|
||||
<p className="font-bold text-gray-900 mb-2">{label}</p>
|
||||
<p className="text-purple-600 font-bold">{Number(payload[0].value).toLocaleString()} تومان</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||
@ -133,11 +166,11 @@ export default function Reports() {
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">سهم فروش دستهبندیها</h3>
|
||||
<div className="h-64 w-full flex justify-center">
|
||||
{categoryDistribution?.length > 0 ? (
|
||||
{categoryDistribution && categoryDistribution.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={categoryDistribution} cx="50%" cy="50%" innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
|
||||
{categoryDistribution.map((_: any, index: number) => (
|
||||
{categoryDistribution.map((_, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@ -177,8 +210,8 @@ export default function Reports() {
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-6">پرکاربردترین کدهای تخفیف</h3>
|
||||
<div className="space-y-4">
|
||||
{topCoupons?.length > 0 ? (
|
||||
topCoupons.map((coupon: any, idx: number) => (
|
||||
{topCoupons && topCoupons.length > 0 ? (
|
||||
topCoupons.map((coupon: TopCouponItem, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 font-bold flex items-center justify-center text-sm">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings as SettingsIcon, Save, Truck, ShieldAlert, Percent, AlertCircle, CreditCard, Share2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
|
||||
@ -9,6 +10,7 @@ export default function Settings() {
|
||||
MIN_ORDER_AMOUNT: '',
|
||||
B2B_DISCOUNT_PERCENT: '',
|
||||
MAINTENANCE_MODE: 'false',
|
||||
CATALOG_ONLY_MODE: 'false',
|
||||
CHARITY_ROUND_STEP: '10000',
|
||||
PAY_GATEWAY_CARD_ENABLE: 'true',
|
||||
PAY_GATEWAY_WALLET_ENABLE: 'true',
|
||||
@ -20,20 +22,25 @@ export default function Settings() {
|
||||
SOCIAL_INSTAGRAM: 'https://instagram.com/canina_iran',
|
||||
SOCIAL_TELEGRAM: 'https://t.me/canina_iran',
|
||||
CONTACT_ADDRESS: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
||||
BRAND_LOGO_URL: '/logo.png',
|
||||
ENAMAD_CODE: '',
|
||||
THEME_PRIMARY_COLOR: '#7c3aed',
|
||||
BRAND_TYPOGRAPHY: 'vazirmatn',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await api.get('/admin/settings');
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
api.get('/admin/settings').then(response => {
|
||||
if (!isSubscribed) return;
|
||||
if (response.data?.success) {
|
||||
setSettings({
|
||||
SHIPPING_FEE: response.data.data.SHIPPING_FEE || '0',
|
||||
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || '0',
|
||||
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || '0',
|
||||
MAINTENANCE_MODE: response.data.data.MAINTENANCE_MODE || 'false',
|
||||
CATALOG_ONLY_MODE: response.data.data.CATALOG_ONLY_MODE || 'false',
|
||||
CHARITY_ROUND_STEP: response.data.data.CHARITY_ROUND_STEP || '10000',
|
||||
PAY_GATEWAY_CARD_ENABLE: response.data.data.PAY_GATEWAY_CARD_ENABLE || 'true',
|
||||
PAY_GATEWAY_WALLET_ENABLE: response.data.data.PAY_GATEWAY_WALLET_ENABLE || 'true',
|
||||
@ -45,17 +52,20 @@ export default function Settings() {
|
||||
SOCIAL_INSTAGRAM: response.data.data.SOCIAL_INSTAGRAM || 'https://instagram.com/canina_iran',
|
||||
SOCIAL_TELEGRAM: response.data.data.SOCIAL_TELEGRAM || 'https://t.me/canina_iran',
|
||||
CONTACT_ADDRESS: response.data.data.CONTACT_ADDRESS || 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
||||
BRAND_LOGO_URL: response.data.data.BRAND_LOGO_URL || '/logo.png',
|
||||
ENAMAD_CODE: response.data.data.ENAMAD_CODE || '',
|
||||
THEME_PRIMARY_COLOR: response.data.data.THEME_PRIMARY_COLOR || '#7c3aed',
|
||||
BRAND_TYPOGRAPHY: response.data.data.BRAND_TYPOGRAPHY || 'vazirmatn',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch settings', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
@ -63,9 +73,10 @@ export default function Settings() {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.put('/admin/settings', settings);
|
||||
alert('تنظیمات با موفقیت بروزرسانی شد.');
|
||||
toast.success('تنظیمات با موفقیت بروزرسانی شد.');
|
||||
} catch (err) {
|
||||
console.error('Failed to update settings', err);
|
||||
toast.error('خطا در بروزرسانی تنظیمات');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@ -309,30 +320,117 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Settings */}
|
||||
{/* Brand Identity & Theme Settings (TASK-2.15) */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||||
<SettingsIcon className="w-5 h-5 text-purple-600" />
|
||||
مدیریت هویت برند، نمادها و ظاهر سایت (TASK-2.15)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">لینک لوگوی برند (Brand Logo URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.BRAND_LOGO_URL}
|
||||
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">کد / شناسه اینماد (Enamad Code/HTML)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ENAMAD_CODE}
|
||||
onChange={(e) => setSettings({ ...settings, ENAMAD_CODE: e.target.value })}
|
||||
placeholder="https://trustseal.enamad.ir/..."
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">رنگ اصلی تم سایت (Hex Color)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={settings.THEME_PRIMARY_COLOR}
|
||||
onChange={(e) => setSettings({ ...settings, THEME_PRIMARY_COLOR: e.target.value })}
|
||||
className="w-10 h-10 rounded-lg cursor-pointer border border-gray-200"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.THEME_PRIMARY_COLOR}
|
||||
onChange={(e) => setSettings({ ...settings, THEME_PRIMARY_COLOR: e.target.value })}
|
||||
className="flex-1 border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">تایپوگرافی و فونت سایت</label>
|
||||
<select
|
||||
value={settings.BRAND_TYPOGRAPHY}
|
||||
onChange={(e) => setSettings({ ...settings, BRAND_TYPOGRAPHY: e.target.value })}
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold"
|
||||
>
|
||||
<option value="vazirmatn">وزیرمتن (Vazirmatn)</option>
|
||||
<option value="iransans">ایرانسانس (IRANSans)</option>
|
||||
<option value="dana">دانا (Dana)</option>
|
||||
<option value="shabnam">شبنم (Shabnam)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Settings (TASK-2.14) */}
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-orange-500" />
|
||||
وضعیت سیستم
|
||||
وضعیت سیستم و کنترل حالت کاتالوگ (TASK-2.14)
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-orange-500" />
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900">حالت تعمیرات (Maintenance Mode)</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و پیام «در حال بروزرسانی» نمایش داده میشود.</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-orange-500" />
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900 text-sm">حالت تعمیرات (Maintenance Mode)</h4>
|
||||
<p className="text-xs text-gray-600 mt-0.5">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و صفحه اختصاصی «در حال بروزرسانی» نمایش داده میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.MAINTENANCE_MODE === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, MAINTENANCE_MODE: e.target.checked ? 'true' : 'false' })}
|
||||
/>
|
||||
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-orange-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-xl border border-purple-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h4 className="font-bold text-gray-900 text-sm">حالت کاتالوگ بدون خرید (Catalog Only Mode)</h4>
|
||||
<p className="text-xs text-gray-600 mt-0.5">با فعالسازی این گزینه، دکمههای افزودن به سبد خرید غیرفعال شده و سایت به کاتالوگ آنلاین تبدیل میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.CATALOG_ONLY_MODE === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, CATALOG_ONLY_MODE: e.target.checked ? 'true' : 'false' })}
|
||||
/>
|
||||
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.MAINTENANCE_MODE === 'true'}
|
||||
onChange={(e) => setSettings({ ...settings, MAINTENANCE_MODE: e.target.checked ? 'true' : 'false' })}
|
||||
/>
|
||||
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-orange-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Languages, Save, Search, Loader2, Layout, Sliders, Image, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
|
||||
import { Languages, Save, Search, Loader2, Layout, Sliders, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
|
||||
@ -13,6 +14,9 @@ const PAGE_TABS = [
|
||||
];
|
||||
|
||||
const GROUPS: Record<string, string[]> = {
|
||||
"تنظیمات کاتالوگمود و حالت صیانت": [
|
||||
"maintenance_mode", "catalog_mode", "catalog_hide_prices", "catalog_disable_cart", "catalog_disable_checkout",
|
||||
],
|
||||
"هدر و ناوبری": [
|
||||
"shipping_notice", "brand_name_fa", "brand_subtitle",
|
||||
"nav_solutions", "nav_products", "nav_wiki", "nav_blog", "nav_pet_profiles",
|
||||
@ -93,19 +97,18 @@ export default function UITexts() {
|
||||
const currentKeyForUpload = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTexts();
|
||||
}, []);
|
||||
|
||||
const fetchTexts = async () => {
|
||||
try {
|
||||
const res = await api.get('/settings/ui-texts');
|
||||
setTexts(res.data || {});
|
||||
} catch (e) {
|
||||
let isSubscribed = true;
|
||||
api.get('/settings/ui-texts').then(res => {
|
||||
if (isSubscribed) setTexts(res.data || {});
|
||||
}).catch(e => {
|
||||
console.error('Failed to load UI texts:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const value = edits[key] ?? texts[key] ?? '';
|
||||
@ -114,10 +117,11 @@ export default function UITexts() {
|
||||
await api.put(`/settings/ui-texts/${key}`, { value });
|
||||
setTexts(prev => ({ ...prev, [key]: value }));
|
||||
setSavedKey(key);
|
||||
toast.success('ذخیرهسازی با موفقیت انجام شد');
|
||||
setTimeout(() => setSavedKey(null), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to save UI text:', e);
|
||||
alert('خطا در ذخیرهسازی');
|
||||
toast.error('خطا در ذخیرهسازی');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
@ -136,10 +140,11 @@ export default function UITexts() {
|
||||
await api.put(`/settings/ui-texts/${key}`, { value: fileUrl });
|
||||
setTexts(prev => ({ ...prev, [key]: fileUrl }));
|
||||
setSavedKey(key);
|
||||
toast.success('تصویر با موفقیت آپلود شد');
|
||||
setTimeout(() => setSavedKey(null), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to upload image:', e);
|
||||
alert('خطا در آپلود تصویر');
|
||||
toast.error('خطا در آپلود تصویر');
|
||||
} finally {
|
||||
setUploadingKey(null);
|
||||
}
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
export interface UserRecord {
|
||||
[x: string]: string;
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
|
||||
// Queries
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
@ -20,7 +31,7 @@ export default function Users() {
|
||||
const [editRole, setEditRole] = useState<string>('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
@ -38,7 +49,7 @@ export default function Users() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search, role]);
|
||||
|
||||
useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
@ -46,9 +57,9 @@ export default function Users() {
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [page, search, role]);
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleEditClick = (user: any) => {
|
||||
const handleEditClick = (user: UserRecord) => {
|
||||
setEditingId(user.id);
|
||||
setEditRole(user.role || 'User_PetOwner');
|
||||
};
|
||||
@ -75,9 +86,9 @@ export default function Users() {
|
||||
</h2>
|
||||
<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">
|
||||
<select
|
||||
<select
|
||||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||||
value={role}
|
||||
onChange={(e) => { setRole(e.target.value); setPage(1); }}
|
||||
@ -89,9 +100,9 @@ export default function Users() {
|
||||
</select>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
||||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||||
dir="rtl"
|
||||
value={search}
|
||||
@ -136,7 +147,7 @@ export default function Users() {
|
||||
<td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td>
|
||||
<td className="py-4 px-6">
|
||||
{isEditing ? (
|
||||
<select
|
||||
<select
|
||||
className="border border-purple-300 rounded p-1 text-xs font-bold"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
@ -147,13 +158,12 @@ export default function Users() {
|
||||
<option value="ADMIN">ادمین</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||
user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
||||
user.role?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
||||
user.role?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
||||
user.role?.includes('B2B') ? 'همکار (B2B)' :
|
||||
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
|
||||
user.role?.includes('B2B') ? 'همکار (B2B)' :
|
||||
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, CheckCircle, X, Upload, Image as ImageIcon, Film, Code } from 'lucide-react';
|
||||
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, X, Upload, Film } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
@ -21,6 +23,7 @@ export default function Videos() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isUploadingThumbnail, setIsUploadingThumbnail] = useState(false);
|
||||
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
|
||||
|
||||
@ -37,22 +40,20 @@ export default function Videos() {
|
||||
isFeatured: false,
|
||||
});
|
||||
|
||||
const fetchVideos = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.get('/videos', {
|
||||
params: { search: searchTerm, limit: 100 },
|
||||
});
|
||||
setVideos(res.data.data || res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch videos:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
let isSubscribed = true;
|
||||
api.get('/videos', {
|
||||
params: { search: searchTerm, limit: 100 },
|
||||
}).then(res => {
|
||||
if (isSubscribed) setVideos(res.data.data || res.data || []);
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch videos:', err);
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleOpenModal = (video?: Video) => {
|
||||
@ -82,16 +83,24 @@ export default function Videos() {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File, type: 'thumbnail' | 'video') => {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement> | File, type: 'thumbnail' | 'video') => {
|
||||
let file: File | undefined;
|
||||
if (e instanceof File) {
|
||||
file = e;
|
||||
} else {
|
||||
file = e.target.files?.[0];
|
||||
}
|
||||
if (!file) return;
|
||||
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('file', file);
|
||||
|
||||
if (type === 'thumbnail') setIsUploadingThumbnail(true);
|
||||
if (type === 'video') setIsUploadingVideo(true);
|
||||
else setIsUploadingVideo(true);
|
||||
|
||||
try {
|
||||
const res = await api.post('/cms/upload', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
const res = await api.post('/admin/media/upload', uploadData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
const fileUrl = res.data.url || res.data.fileUrl;
|
||||
if (type === 'thumbnail') {
|
||||
@ -99,9 +108,10 @@ export default function Videos() {
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, videoUrl: fileUrl }));
|
||||
}
|
||||
toast.success('فایل با موفقیت آپلود شد');
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
alert('خطا در آپلود فایل');
|
||||
toast.error('خطا در آپلود فایل');
|
||||
} finally {
|
||||
setIsUploadingThumbnail(false);
|
||||
setIsUploadingVideo(false);
|
||||
@ -113,25 +123,30 @@ export default function Videos() {
|
||||
try {
|
||||
if (editingVideo) {
|
||||
await api.put(`/videos/${editingVideo.id}`, formData);
|
||||
toast.success('ویدئو با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await api.post('/videos', formData);
|
||||
toast.success('ویدئو جدید با موفقیت ذخیره شد');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchVideos();
|
||||
fetchVideosList();
|
||||
} catch (err) {
|
||||
console.error('Failed to save video:', err);
|
||||
alert('خطا در ذخیرهسازی ویدئو');
|
||||
toast.error('خطا در ذخیرهسازی ویدئو');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm('آیا از حذف این ویدئوی آموزشی اطمینان دارید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/videos/${id}`);
|
||||
fetchVideos();
|
||||
await api.delete(`/videos/${deleteTargetId}`);
|
||||
toast.success('ویدئو با موفقیت حذف شد');
|
||||
fetchVideosList();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete video:', err);
|
||||
alert('خطا در حذف ویدئو');
|
||||
toast.error('خطا در حذف ویدئو');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -233,7 +248,7 @@ export default function Videos() {
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(video.id)}
|
||||
onClick={() => setDeleteTargetId(video.id)}
|
||||
className="p-2 hover:bg-red-50 text-red-600 rounded-xl transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
@ -412,6 +427,19 @@ export default function Videos() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetId}
|
||||
title="حذف ویدئوی آموزشی"
|
||||
message="آیا از حذف این ویدئوی آموزشی اطمینان دارید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fetchVideosList() {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
|
||||
|
||||
@ -1,45 +1,70 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface WholesaleRequest {
|
||||
id: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
mobile?: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function WholesaleApplications() {
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [requests, setRequests] = useState<WholesaleRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [approveTargetId, setApproveTargetId] = useState<string | null>(null);
|
||||
const [rejectTargetId, setRejectTargetId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, []);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
setLoading(true);
|
||||
const fetchRequestsData = async () => {
|
||||
try {
|
||||
const res = await api.get('/wholesale/requests');
|
||||
setRequests(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch wholesale requests:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
api.get('/wholesale/requests').then(res => {
|
||||
if (isMounted) setRequests(res.data || []);
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch wholesale requests:', err);
|
||||
}).finally(() => {
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const confirmApprove = async () => {
|
||||
if (!approveTargetId) return;
|
||||
try {
|
||||
await api.put(`/wholesale/approve/${approveTargetId}`);
|
||||
toast.success('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.');
|
||||
fetchRequestsData();
|
||||
} catch {
|
||||
toast.error('خطا در ارتقای نقش کاربر');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setApproveTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (userId: string) => {
|
||||
if (!confirm('آیا از تایید حساب همکار و اعطای قیمتهای عمده-۳۰٪ مطمئن هستید؟')) return;
|
||||
const confirmReject = async () => {
|
||||
if (!rejectTargetId) return;
|
||||
try {
|
||||
await api.put(`/wholesale/approve/${userId}`);
|
||||
alert('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.');
|
||||
fetchRequests();
|
||||
} catch (err) {
|
||||
alert('خطا در ارتقای نقش کاربر');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (userId: string) => {
|
||||
if (!confirm('آیا از رد درخواست همکاری مطمئن هستید؟')) return;
|
||||
try {
|
||||
await api.put(`/wholesale/reject/${userId}`);
|
||||
alert('درخواست رد شد.');
|
||||
fetchRequests();
|
||||
} catch (err) {
|
||||
alert('خطا در رد درخواست');
|
||||
await api.put(`/wholesale/reject/${rejectTargetId}`);
|
||||
toast.success('درخواست رد شد.');
|
||||
fetchRequestsData();
|
||||
} catch {
|
||||
toast.error('خطا در رد درخواست');
|
||||
} finally {
|
||||
setRejectTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,16 +92,15 @@ export default function WholesaleApplications() {
|
||||
<td colSpan={4} className="text-center py-8 text-gray-400 font-medium">هیچ درخواستی در انتظار بررسی یافت نشد.</td>
|
||||
</tr>
|
||||
) : (
|
||||
requests.map((u: any) => (
|
||||
requests.map((u: WholesaleRequest) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50/60 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-gray-900">{u.fullName || u.email || 'کاربر بدون نام'}</div>
|
||||
<div className="text-xs text-gray-400 dir-ltr text-right">{u.email || u.phoneNumber}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex px-3 py-1 rounded-full text-xs font-bold ${
|
||||
u.role === 'User_Wholesale' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
<span className={`inline-flex px-3 py-1 rounded-full text-xs font-bold ${u.role === 'User_Wholesale' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
{u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'}
|
||||
</span>
|
||||
</td>
|
||||
@ -86,14 +110,14 @@ export default function WholesaleApplications() {
|
||||
<td className="px-6 py-4 text-center space-x-2 space-x-reverse">
|
||||
{u.role !== 'User_Wholesale' && (
|
||||
<button
|
||||
onClick={() => handleApprove(u.id)}
|
||||
onClick={() => setApproveTargetId(u.id)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white font-bold text-xs px-4 py-2 rounded-xl transition-all shadow-sm"
|
||||
>
|
||||
تایید و اعطای لایسنس B2B
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleReject(u.id)}
|
||||
onClick={() => setRejectTargetId(u.id)}
|
||||
className="bg-gray-100 hover:bg-red-50 hover:text-red-600 text-gray-600 font-bold text-xs px-4 py-2 rounded-xl transition-all"
|
||||
>
|
||||
رد درخواست
|
||||
@ -106,6 +130,26 @@ export default function WholesaleApplications() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!approveTargetId}
|
||||
title="تایید حساب همکار B2B"
|
||||
message="آیا از تایید حساب همکار و اعطای قیمتهای عمده-۳۰٪ مطمئن هستید؟"
|
||||
confirmText="تایید و اعطا"
|
||||
isDestructive={false}
|
||||
onConfirm={confirmApprove}
|
||||
onCancel={() => setApproveTargetId(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!rejectTargetId}
|
||||
title="رد درخواست همکاری"
|
||||
message="آیا از رد درخواست همکاری مطمئن هستید؟"
|
||||
confirmText="رد درخواست"
|
||||
isDestructive={true}
|
||||
onConfirm={confirmReject}
|
||||
onCancel={() => setRejectTargetId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
|
||||
export interface WikiTerm {
|
||||
key: string;
|
||||
term: string;
|
||||
definition: string;
|
||||
relatedProducts?: string[];
|
||||
wikiId?: string;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
nameFa: string;
|
||||
artNo: string;
|
||||
}
|
||||
|
||||
export default function Wiki() {
|
||||
const [terms, setTerms] = useState<any[]>([]);
|
||||
const [terms, setTerms] = useState<WikiTerm[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@ -13,19 +32,21 @@ export default function Wiki() {
|
||||
const limit = 10;
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingTerm, setEditingTerm] = useState<any>(null);
|
||||
|
||||
const [editingTerm, setEditingTerm] = useState<WikiTerm | null>(null);
|
||||
const [deleteTargetKey, setDeleteTargetKey] = useState<string | null>(null);
|
||||
const [productsList, setProductsList] = useState<ProductItem[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
term: '',
|
||||
definition: '',
|
||||
relatedProducts: [] as string[],
|
||||
wikiId: 'general',
|
||||
metaTitle: '',
|
||||
metaDescription: '',
|
||||
keywords: ''
|
||||
});
|
||||
|
||||
const fetchTerms = async () => {
|
||||
const fetchTerms = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
|
||||
@ -38,49 +59,62 @@ export default function Wiki() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchTerms();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, page]);
|
||||
}, [fetchTerms]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingTerm) {
|
||||
await api.put(`/admin/wiki/${editingTerm.key}`, formData);
|
||||
toast.success('اصطلاح علمی با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await api.post('/admin/wiki', formData);
|
||||
toast.success('اصطلاح علمی جدید با موفقیت ایجاد شد');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchTerms();
|
||||
} catch (error) {
|
||||
console.error('Save failed', error);
|
||||
alert('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
||||
toast.error('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
if (!window.confirm('آیا از حذف این مورد مطمئن هستید؟')) return;
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetKey) return;
|
||||
try {
|
||||
await api.delete(`/admin/wiki/${key}`);
|
||||
await api.delete(`/admin/wiki/${deleteTargetKey}`);
|
||||
toast.success('اصطلاح علمی با موفقیت حذف شد');
|
||||
fetchTerms();
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف اصطلاح علمی');
|
||||
} finally {
|
||||
setDeleteTargetKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (term: any = null) => {
|
||||
useEffect(() => {
|
||||
api.get('/admin/products', { params: { limit: 100 } })
|
||||
.then(res => setProductsList(res.data?.data || []))
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
const openModal = (term: WikiTerm | null = null) => {
|
||||
if (term) {
|
||||
setEditingTerm(term);
|
||||
setFormData({
|
||||
key: term.key,
|
||||
term: term.term,
|
||||
definition: term.definition,
|
||||
wikiId: term.wikiId,
|
||||
relatedProducts: Array.isArray(term.relatedProducts) ? term.relatedProducts : [],
|
||||
wikiId: term.wikiId || 'general',
|
||||
metaTitle: term.metaTitle || '',
|
||||
metaDescription: term.metaDescription || '',
|
||||
keywords: term.keywords || ''
|
||||
@ -88,7 +122,7 @@ export default function Wiki() {
|
||||
} else {
|
||||
setEditingTerm(null);
|
||||
setFormData({
|
||||
key: '', term: '', definition: '', wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
||||
key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
@ -151,7 +185,7 @@ export default function Wiki() {
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openModal(t)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTargetKey(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -197,6 +231,43 @@ export default function Wiki() {
|
||||
<textarea required value={formData.definition} onChange={(e) => setFormData({ ...formData, definition: e.target.value })} rows={5} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||||
</div>
|
||||
|
||||
{/* Related Products Selector (TASK-2.23) */}
|
||||
<div className="space-y-2 border border-gray-200 p-4 rounded-xl bg-purple-50/40">
|
||||
<label className="text-xs font-bold text-gray-800 block">محصولات مرتبط با این مقاله علمی (TASK-2.23)</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const prodId = e.target.value;
|
||||
if (prodId && !formData.relatedProducts.includes(prodId)) {
|
||||
setFormData({ ...formData, relatedProducts: [...formData.relatedProducts, prodId] });
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white"
|
||||
>
|
||||
<option value="">+ انتخاب محصول جهت افزودن به لیست مرتبط</option>
|
||||
{productsList.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.nameFa} ({p.artNo})</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.relatedProducts.map(pId => {
|
||||
const prod = productsList.find(p => p.id === pId);
|
||||
return (
|
||||
<span key={pId} className="inline-flex items-center gap-1.5 px-3 py-1 bg-purple-100 text-purple-700 text-xs font-bold rounded-full">
|
||||
<span>{prod ? prod.nameFa : pId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, relatedProducts: formData.relatedProducts.filter(id => id !== pId) })}
|
||||
className="hover:text-red-600 font-black text-sm"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
||||
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||||
<div className="space-y-2">
|
||||
@ -223,6 +294,14 @@ export default function Wiki() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTargetKey}
|
||||
title="حذف اصطلاح علمی"
|
||||
message="آیا از حذف این مورد مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteTargetKey(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export interface ApiErrorPayload {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
@ -9,11 +9,14 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import CartDrawer from "../components/CartDrawer";
|
||||
import LoginModal from "../components/LoginModal";
|
||||
import AuthModal from "../components/AuthModal";
|
||||
import B2BPortal from "../components/B2BPortal";
|
||||
import { useUserStore } from "../lib/store/userStore";
|
||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||
import { useUIStore } from "../lib/store/uiStore";
|
||||
|
||||
import MaintenancePage from "../components/MaintenancePage";
|
||||
|
||||
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
isCartOpen, isLoginModalOpen, isB2BPortalOpen, advisorData,
|
||||
@ -23,8 +26,9 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const { fetchProfile } = useUserStore();
|
||||
const { fetchProfile, role } = useUserStore();
|
||||
const fetchSettings = useSettingsStore(state => state.fetchSettings);
|
||||
const getText = useSettingsStore(state => state.getText);
|
||||
const isInitialized = useSettingsStore(state => state.isInitialized);
|
||||
|
||||
useEffect(() => {
|
||||
@ -37,7 +41,13 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
||||
}
|
||||
}, [fetchProfile, fetchSettings]);
|
||||
|
||||
// Remove full-page blocking screen to allow immediate render
|
||||
// Check Maintenance Mode (Admin / Partner bypass)
|
||||
const isMaintenanceMode = getText('maintenance_mode', 'false') === 'true';
|
||||
const isAdmin = role === 'User_Partner' || (typeof window !== 'undefined' && Boolean(localStorage.getItem('adminToken')));
|
||||
|
||||
if (isMaintenanceMode && !isAdmin) {
|
||||
return <MaintenancePage />;
|
||||
}
|
||||
|
||||
// Derived currentView from pathname for Header
|
||||
let currentView = "home";
|
||||
@ -103,9 +113,13 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
||||
onLogin={() => {
|
||||
setLoginModalOpen(false);
|
||||
}}
|
||||
petName={advisorData?.name}
|
||||
petName={advisorData?.name as string | undefined}
|
||||
isAdvisorContext={!!advisorData}
|
||||
/>
|
||||
<AuthModal
|
||||
isOpen={useUserStore(state => state.isAuthModalOpen)}
|
||||
onClose={() => useUserStore.getState().setAuthModalOpen(false)}
|
||||
/>
|
||||
<Toaster position="top-center" expand={true} richColors closeButton />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import SafeImage from "../../components/SafeImage";
|
||||
import BackButton from "../../components/BackButton";
|
||||
import { Download, Printer, ShieldCheck, Sparkles, CheckCircle2, ChevronLeft, Search, Filter } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { productService } from "../../lib/services/productService";
|
||||
import { Product } from "../../lib/data/products";
|
||||
import { toPersian } from "../../lib/utils";
|
||||
|
||||
|
||||
export default function CatalogClient() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -89,6 +91,7 @@ export default function CatalogClient() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<BackButton className="bg-white/10 text-white border-white/20 hover:bg-white/20 hover:text-white" />
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="bg-amber-400 hover:bg-amber-500 text-slate-900 font-black px-6 py-4 rounded-2xl shadow-xl hover:scale-105 transition-all flex items-center gap-3 text-sm cursor-pointer"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone, MapPin, Clock, Send } from "lucide-react";
|
||||
import ContactFormClient from "@/components/ContactFormClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "تماس با ما - نمایندگی رسمی مکملهای کانینا آلمان در ایران",
|
||||
@ -35,83 +35,10 @@ export default function ContactPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-12 gap-12 items-start">
|
||||
{/* Contact Details */}
|
||||
<div className="lg:col-span-5 space-y-8">
|
||||
<h3 className="text-2xl font-black text-medical-gray-900">اطلاعات نمایندگی</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<Phone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">تلفنهای تماس</h4>
|
||||
<p className="text-lg font-black text-medical-gray-900 tracking-wider">۰۲۱-۸۸۸۸ ۴۴۴۴</p>
|
||||
<p className="text-sm text-medical-gray-500 font-bold">شنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<Mail className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">پست الکترونیک</h4>
|
||||
<p className="text-sm font-black text-medical-gray-900">info@canina-iran.com</p>
|
||||
<p className="text-xs text-medical-gray-500 font-bold">پاسخگویی در کمتر از ۲۴ ساعت کاری</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<MapPin className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">نشانی دفتر مرکزی</h4>
|
||||
<p className="text-sm font-bold text-medical-gray-900 leading-relaxed">
|
||||
تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7 bg-white border border-medical-gray-200 rounded-[3rem] p-10 shadow-xl">
|
||||
<h3 className="text-2xl font-black text-medical-gray-900 mb-8">ارسال پیام مستقیم</h3>
|
||||
|
||||
<form className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">نام و نام خانوادگی</label>
|
||||
<input type="text" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">شماره موبایل</label>
|
||||
<input type="text" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold text-left focus:outline-none focus:ring-2 focus:ring-canina-blue/20" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">موضوع پیام</label>
|
||||
<input type="text" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">متن پیام</label>
|
||||
<textarea rows={4} className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" className="w-full bg-canina-blue text-white py-4 rounded-2xl font-black flex items-center justify-center gap-2 hover:shadow-xl hover:shadow-canina-blue/20 transition-all">
|
||||
<Send className="w-5 h-5 rotate-180" />
|
||||
ارسال پیام
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ContactFormClient />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -102,9 +102,9 @@
|
||||
font-family: "Vazirmatn" !important;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html, body {
|
||||
font-family: "Vazirmatn" !important;
|
||||
@apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw];
|
||||
@apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw] overflow-x-hidden;
|
||||
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
|
||||
}
|
||||
|
||||
@ -175,3 +175,22 @@
|
||||
.sleek-hscroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #003da6;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: marquee 25s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export async function generateMetadata(
|
||||
|
||||
if (!product) {
|
||||
return {
|
||||
title: 'محصول یافت نشد',
|
||||
title: 'خرید مکمل درمانی پت | کانینا ایران',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { useCartStore } from "../lib/store/cartStore";
|
||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
@ -162,8 +163,12 @@ const ArchiveProductCard: React.FC<{ product: Product }> = ({ product }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-4 border-t border-medical-gray-50 gap-2">
|
||||
<span className="font-black text-medical-gray-900 font-vazir text-sm sm:text-base">{product.price}</span>
|
||||
{(() => {
|
||||
{!useSettingsStore.getState().getText("catalog_hide_prices", "false").includes("true") ? (
|
||||
<span className="font-black text-medical-gray-900 font-vazir text-sm sm:text-base">{product.price}</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-md">تماس جهت استعلام قیمت</span>
|
||||
)}
|
||||
{!useSettingsStore.getState().getText("catalog_disable_cart", "false").includes("true") && (() => {
|
||||
const cartItem = useCartStore.getState().items.find(i => i.product.id === product.id);
|
||||
if (cartItem && cartItem.quantity > 0) {
|
||||
return (
|
||||
@ -237,6 +242,8 @@ export default function ArchivePage({
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false);
|
||||
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
|
||||
const [sortBy, setSortBy] = useState<string>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [meta, setMeta] = useState({ total: 0, lastPage: 1 });
|
||||
|
||||
@ -336,6 +343,8 @@ export default function ArchivePage({
|
||||
category: selectedCategory,
|
||||
petType: selectedPet,
|
||||
query: searchQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
limit: 999,
|
||||
});
|
||||
|
||||
@ -364,7 +373,7 @@ export default function ArchivePage({
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter]);
|
||||
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter, sortBy, sortOrder]);
|
||||
|
||||
const toggleSymptom = (s: string) => {
|
||||
setActiveSymptoms(prev => prev.includes(s) ? prev.filter(item => item !== s) : [...prev, s]);
|
||||
@ -410,9 +419,17 @@ export default function ArchivePage({
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* Mobile Overlay Backdrop */}
|
||||
{isMobileFilterOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-xs transition-opacity"
|
||||
onClick={() => setIsMobileFilterOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Filters - Collapsible on Mobile */}
|
||||
<aside className={`lg:w-72 flex-shrink-0 space-y-6 ${isMobileFilterOpen ? 'block' : 'hidden lg:block'}`}>
|
||||
<div className="bg-white rounded-[2rem] p-6 border border-medical-gray-200 shadow-sm">
|
||||
<aside className={`lg:w-72 flex-shrink-0 space-y-6 ${isMobileFilterOpen ? 'fixed inset-y-0 right-0 z-50 w-4/5 max-w-xs bg-white p-6 overflow-y-auto shadow-2xl space-y-6' : 'hidden lg:block'}`}>
|
||||
<div className="bg-white rounded-[2rem] lg:p-6 border-0 lg:border lg:border-medical-gray-200 shadow-none lg:shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-canina-blue" />
|
||||
@ -420,7 +437,7 @@ export default function ArchivePage({
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileFilterOpen(false)}
|
||||
className="lg:hidden text-xs text-medical-gray-400 font-bold"
|
||||
className="lg:hidden text-xs text-medical-gray-400 font-bold p-1 hover:text-red-500"
|
||||
>
|
||||
بستن ✕
|
||||
</button>
|
||||
@ -541,17 +558,40 @@ export default function ArchivePage({
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 relative">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between mb-6 gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between mb-6 gap-4">
|
||||
<h2 className="text-3xl font-black text-medical-gray-900">کاتولوگ دارویی <span className="text-canina-blue">Canina</span></h2>
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی محصول..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-full py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full sm:w-auto">
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex items-center gap-2 bg-white border border-medical-gray-200 rounded-2xl px-3 py-2 text-xs font-bold shadow-xs">
|
||||
<span className="text-medical-gray-400 whitespace-nowrap">مرتبسازی:</span>
|
||||
<select
|
||||
value={`${sortBy}:${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [b, o] = e.target.value.split(':');
|
||||
setSortBy(b);
|
||||
setSortOrder(o as 'asc' | 'desc');
|
||||
}}
|
||||
className="bg-transparent font-black text-medical-gray-800 outline-none cursor-pointer"
|
||||
>
|
||||
<option value="createdAt:desc">جدیدترین</option>
|
||||
<option value="createdAt:asc">قدیمیترین</option>
|
||||
<option value="priceValue:asc">ارزانترین</option>
|
||||
<option value="priceValue:desc">گرانترین</option>
|
||||
<option value="nameFa:asc">نام (الف - ی)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجوی محصول..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white border border-medical-gray-200 rounded-full py-2.5 pr-11 pl-4 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
38
frontend/application/components/BackButton.tsx
Normal file
38
frontend/application/components/BackButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface BackButtonProps {
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function BackButton({ className, label = "بازگشت" }: BackButtonProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
// router.back() preserves browser history and scroll position natively
|
||||
if (typeof window !== "undefined" && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm border border-medical-gray-200 hover:border-canina-blue hover:text-canina-blue text-medical-gray-700 text-xs font-black rounded-xl transition-all shadow-xs active:scale-95 whitespace-nowrap cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
248
frontend/application/components/ContactFormClient.tsx
Normal file
248
frontend/application/components/ContactFormClient.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Mail, Phone, MapPin, Send, CheckCircle2 } from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
interface ContactInfoItem {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default function ContactFormClient() {
|
||||
const [infoItems, setInfoItems] = useState<ContactInfoItem[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchContactInfo() {
|
||||
try {
|
||||
const res = await axios.get(`${API_URL}/contact/info`);
|
||||
setInfoItems(res.data || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch contact info", err);
|
||||
}
|
||||
}
|
||||
fetchContactInfo();
|
||||
}, [API_URL]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !phone.trim() || !message.trim()) {
|
||||
setError("لطفاً نام، شماره موبایل و متن پیام را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/contact`, {
|
||||
name,
|
||||
phone,
|
||||
subject,
|
||||
message,
|
||||
});
|
||||
setSuccess(true);
|
||||
setName("");
|
||||
setPhone("");
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.message || "خطایی در ثبت پیام رخ داده است. لطفاً مجدداً تلاش کنید."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (iconName?: string) => {
|
||||
switch (iconName) {
|
||||
case "mail":
|
||||
return <Mail className="w-6 h-6" />;
|
||||
case "map-pin":
|
||||
return <MapPin className="w-6 h-6" />;
|
||||
case "phone":
|
||||
default:
|
||||
return <Phone className="w-6 h-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-12 gap-12 items-start">
|
||||
{/* Contact Details */}
|
||||
<div className="lg:col-span-5 space-y-8">
|
||||
<h3 className="text-2xl font-black text-medical-gray-900">اطلاعات نمایندگی</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
{infoItems.length > 0 ? (
|
||||
infoItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm"
|
||||
>
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
{getIcon(item.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="text-sm font-bold text-medical-gray-900 leading-relaxed whitespace-pre-wrap">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<Phone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
|
||||
تلفنهای تماس
|
||||
</h4>
|
||||
<p className="text-lg font-black text-medical-gray-900 tracking-wider">
|
||||
۰۲۱-۸۸۸۸ ۴۴۴۴
|
||||
</p>
|
||||
<p className="text-sm text-medical-gray-500 font-bold">
|
||||
شنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<Mail className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
|
||||
پست الکترونیک
|
||||
</h4>
|
||||
<p className="text-sm font-black text-medical-gray-900">info@canina-iran.com</p>
|
||||
<p className="text-xs text-medical-gray-500 font-bold">
|
||||
پاسخگویی در کمتر از ۲۴ ساعت کاری
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
|
||||
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
|
||||
<MapPin className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
|
||||
نشانی دفتر مرکزی
|
||||
</h4>
|
||||
<p className="text-sm font-bold text-medical-gray-900 leading-relaxed">
|
||||
تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7 bg-white border border-medical-gray-200 rounded-[3rem] p-10 shadow-xl">
|
||||
<h3 className="text-2xl font-black text-medical-gray-900 mb-8">ارسال پیام مستقیم</h3>
|
||||
|
||||
{success ? (
|
||||
<div className="p-6 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-2xl text-center space-y-3">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-600 mx-auto" />
|
||||
<h4 className="text-lg font-black">پیام شما با موفقیت ثبت شد</h4>
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
پیامک تایید به شماره شما ارسال شد. کارشناسان کانینا به زودی با شما تماس خواهند گرفت.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="mt-4 px-6 py-2 bg-emerald-600 text-white rounded-xl text-xs font-black"
|
||||
>
|
||||
ارسال پیام جدید
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-rose-50 border border-rose-200 text-rose-700 text-xs font-bold rounded-2xl">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">
|
||||
نام و نام خانوادگی *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
placeholder="مثال: علی محمدی"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">شماره موبایل *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold text-left focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
dir="ltr"
|
||||
placeholder="09123456789"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">موضوع پیام</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
placeholder="مثال: مشاوره تخصصی مکمل یا خرید عمده"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black text-medical-gray-500">متن پیام *</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
|
||||
placeholder="متن درخواست یا پیام خود را بنویسید..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-canina-blue text-white py-4 rounded-2xl font-black flex items-center justify-center gap-2 hover:shadow-xl hover:shadow-canina-blue/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-5 h-5 rotate-180" />
|
||||
{loading ? "در حال ثبت..." : "ارسال پیام"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,8 +9,8 @@ export const NotFoundPage = () => {
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center p-6" dir="rtl">
|
||||
<div className="max-w-lg w-full text-center">
|
||||
<div className="relative mb-12">
|
||||
<div className="text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div>
|
||||
<div className="relative mb-12 overflow-hidden">
|
||||
<div className="text-[7rem] sm:text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-32 h-32 bg-white rounded-[3rem] shadow-2xl flex items-center justify-center">
|
||||
<FileQuestion className="w-16 h-16 text-canina-blue" />
|
||||
|
||||
@ -11,6 +11,7 @@ import { toast } from "sonner";
|
||||
import HeaderButton from "./HeaderButton";
|
||||
import AuthModal from "./AuthModal";
|
||||
import PrescriptionUploadModal from "./PrescriptionUploadModal";
|
||||
import TickerBanner from "./TickerBanner";
|
||||
import { cn } from "../lib/utils";
|
||||
import { productService } from "../lib/services/productService";
|
||||
|
||||
@ -132,11 +133,8 @@ export default function Header({
|
||||
<PrescriptionUploadModal isOpen={isPrescriptionModalOpen} onClose={() => setIsPrescriptionModalOpen(false)} />
|
||||
|
||||
<div className="sticky top-0 z-50 w-full shadow-md font-vazir" dir="rtl">
|
||||
{/* Top Free Shipping Notice */}
|
||||
<div className="bg-canina-gold text-canina-dark text-xs font-black py-1.5 text-center tracking-wider flex items-center justify-center gap-2">
|
||||
<span>🚚</span>
|
||||
<span>{getText('shipping_notice', "ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان — گارانتی اصالت کانینا آلمان")}</span>
|
||||
</div>
|
||||
{/* Sliding Announcement Ticker Banner */}
|
||||
<TickerBanner />
|
||||
|
||||
<header className="bg-white border-b border-medical-gray-200">
|
||||
{/* TOP ROW: Logo + Search + Primary Actions */}
|
||||
@ -400,47 +398,56 @@ export default function Header({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Navigation Drawer */}
|
||||
{/* Mobile Navigation Drawer & Backdrop */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="lg:hidden bg-white border-b border-medical-gray-200 px-4 py-6 space-y-4"
|
||||
>
|
||||
<form onSubmit={handleSearch} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در محصولات..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-10 pl-4 text-xs font-bold"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-medical-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</form>
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-xs"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="lg:hidden relative z-50 bg-white border-b border-medical-gray-200 px-4 py-6 space-y-4 shadow-xl"
|
||||
>
|
||||
<form onSubmit={handleSearch} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در محصولات..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-10 pl-4 text-xs font-bold"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-medical-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</form>
|
||||
|
||||
<div className="space-y-2 text-sm font-black">
|
||||
<Link href="/shop" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900">
|
||||
🛒 محصولات تخصصی کانینا
|
||||
</Link>
|
||||
<Link href="/catalog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
📑 کاتالوگ آنلاین و دوز مصرفی
|
||||
</Link>
|
||||
<Link href="/wiki" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🧬 دانشنامه علمی
|
||||
</Link>
|
||||
<Link href="/blog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
📰 مجله سلامت پت
|
||||
</Link>
|
||||
<Link href="/videos" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🎥 آکادمی ویدئویی و مشاوره دامپزشک
|
||||
</Link>
|
||||
<Link href="/profile" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🐾 شناسنامه و سوابق سلامت پت
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="space-y-2 text-sm font-black">
|
||||
<Link href="/shop" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900">
|
||||
🛒 محصولات تخصصی کانینا
|
||||
</Link>
|
||||
<Link href="/catalog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
📑 کاتالوگ آنلاین و دوز مصرفی
|
||||
</Link>
|
||||
<Link href="/wiki" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🧬 دانشنامه علمی
|
||||
</Link>
|
||||
<Link href="/blog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
📰 مجله سلامت پت
|
||||
</Link>
|
||||
<Link href="/videos" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🎥 آکادمی ویدئویی و مشاوره دامپزشک
|
||||
</Link>
|
||||
<Link href="/profile" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
|
||||
🐾 شناسنامه و سوابق سلامت پت
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@ -33,6 +33,64 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handleVerifyOtpWithCode = async (code: string) => {
|
||||
const cleanCode = code.trim();
|
||||
if (cleanCode.length !== 5) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
|
||||
if (response.success) {
|
||||
await fetchProfile();
|
||||
toast.success("ورود با موفقیت انجام شد");
|
||||
onLogin();
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "کد تایید اشتباه است");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// WebOTP API & Auto-Submit
|
||||
useEffect(() => {
|
||||
if (step === "otp" && typeof window !== "undefined" && "OTPCredential" in window) {
|
||||
const ac = new AbortController();
|
||||
(navigator as any).credentials
|
||||
.get({
|
||||
otp: { transport: ["sms"] },
|
||||
signal: ac.signal,
|
||||
})
|
||||
.then((otp: any) => {
|
||||
if (otp && otp.code) {
|
||||
setOtpCode(otp.code);
|
||||
handleVerifyOtpWithCode(otp.code);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => ac.abort();
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handleOtpInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, "").slice(0, 5);
|
||||
setOtpCode(val);
|
||||
if (val.length === 5) {
|
||||
handleVerifyOtpWithCode(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData("text").replace(/[^0-9]/g, "").slice(0, 5);
|
||||
if (pasted.length === 5) {
|
||||
e.preventDefault();
|
||||
setOtpCode(pasted);
|
||||
handleVerifyOtpWithCode(pasted);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset modal state when closed or opened
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@ -79,26 +137,7 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const cleanCode = otpCode.trim();
|
||||
if (cleanCode.length !== 5) {
|
||||
toast.error("کد تایید باید ۵ رقم باشد");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
|
||||
if (response.success) {
|
||||
await fetchProfile();
|
||||
toast.success("ورود با موفقیت انجام شد");
|
||||
onLogin();
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "کد تایید اشتباه است");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
handleVerifyOtpWithCode(otpCode);
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
@ -234,9 +273,11 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
||||
ref={otpInputRef}
|
||||
type="text"
|
||||
maxLength={5}
|
||||
autoComplete="one-time-code"
|
||||
placeholder="کد ۵ رقمی"
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
onChange={handleOtpInputChange}
|
||||
onPaste={handleOtpPaste}
|
||||
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 focus:border-canina-blue outline-none font-black text-2xl text-center tracking-[0.5em]"
|
||||
/>
|
||||
|
||||
47
frontend/application/components/MaintenancePage.tsx
Normal file
47
frontend/application/components/MaintenancePage.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Wrench, ShieldCheck, Phone, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-medical-gray-900 text-white flex flex-col items-center justify-center p-6 text-center font-vazir relative overflow-hidden" dir="rtl">
|
||||
{/* Glow Effects */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-canina-blue/20 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-10 right-10 w-80 h-80 bg-canina-gold/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 max-w-xl mx-auto space-y-6">
|
||||
<div className="w-24 h-24 bg-white/10 border border-white/20 rounded-3xl mx-auto flex items-center justify-center text-canina-gold shadow-2xl backdrop-blur-md">
|
||||
<Wrench className="w-12 h-12 animate-bounce" />
|
||||
</div>
|
||||
|
||||
<span className="inline-block px-4 py-1.5 bg-canina-gold/20 text-canina-gold border border-canina-gold/30 rounded-full text-xs font-black tracking-widest uppercase">
|
||||
سامانه در حال بهروزرسانی و ارتقا
|
||||
</span>
|
||||
|
||||
<h1 className="text-3xl sm:text-5xl font-black tracking-tight leading-tight">
|
||||
بهزودی با خدمات کاملتر بازمیگردیم
|
||||
</h1>
|
||||
|
||||
<p className="text-sm sm:text-base text-medical-gray-300 leading-relaxed">
|
||||
وبسایت کانینا ایران جهت ارتقای زیرساختها و ارائه خدمات بهتر به شما سرپرستان و دامپزشکان محترم به صورت موقت در دست بهروزرسانی است. از شکیبایی شما سپاسگزاریم.
|
||||
</p>
|
||||
|
||||
<div className="pt-4 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a
|
||||
href="tel:0218888"
|
||||
className="w-full sm:w-auto px-6 py-3.5 bg-canina-blue hover:bg-canina-dark text-white font-black text-xs rounded-xl flex items-center justify-center gap-2 shadow-lg transition-all"
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>پشتیبانی اضطراری: ۰۲۱-۸۸۸۸</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 text-xs text-medical-gray-500 font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-canina-gold" />
|
||||
<span>CANINA PHARMA GMBH GERMANY — نماینده رسمی ایران</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -72,13 +72,6 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
||||
}
|
||||
}, [initialView]);
|
||||
|
||||
if (isLoading && view === "detail" && activePet) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-20" dir="rtl">
|
||||
<PetProfileSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [activePetTab, setActivePetTab] = useState<"health" | "orders">("health");
|
||||
const [petToDelete, setPetToDelete] = useState<GlobalPetProfile | null>(null);
|
||||
|
||||
@ -121,54 +114,10 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
||||
consumptions: []
|
||||
});
|
||||
|
||||
const handleHealthLogSubmit = () => {
|
||||
if (activePet) {
|
||||
addHealthLog(activePet.id, logForm);
|
||||
toast.success("گزارش سلامت با موفقیت ثبت شد");
|
||||
setIsAddingHealthLog(false);
|
||||
setLogForm({
|
||||
appetite: "عالی",
|
||||
energy: "نرمال",
|
||||
digestion: "نرمال",
|
||||
note: ""
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReminderSubmit = () => {
|
||||
if (activePet) {
|
||||
if (!reminderForm.title) {
|
||||
toast.error("لطفاً عنوان یادآور را وارد کنید");
|
||||
return;
|
||||
}
|
||||
addReminder(activePet.id, reminderForm);
|
||||
toast.success("یادآور با موفقیت ثبت شد");
|
||||
setIsAddingReminder(false);
|
||||
setReminderForm({
|
||||
title: "",
|
||||
time: "08:00",
|
||||
frequency: "روزانه",
|
||||
productId: ""
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReminder = (reminder: Reminder) => {
|
||||
if (activePet) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
toggleReminder(activePet.id, reminder.id, today);
|
||||
|
||||
const isCompleting = !reminder.completedDates.includes(today);
|
||||
if (isCompleting) {
|
||||
toast.success(`دوز ${reminder.title} تایید شد`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const recommendedProducts = useMemo(() => {
|
||||
if (!activePet || products.length === 0) return [];
|
||||
|
||||
let picks: { product: Product, reason?: string }[] = [];
|
||||
const picks: { product: Product, reason?: string }[] = [];
|
||||
|
||||
// Priority 1: Match activePet.medicalConditions directly against product.symptoms
|
||||
if (activePet.medicalConditions && activePet.medicalConditions.length > 0) {
|
||||
@ -220,6 +169,50 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const handleHealthLogSubmit = () => {
|
||||
if (activePet) {
|
||||
addHealthLog(activePet.id, logForm);
|
||||
toast.success("گزارش سلامت با موفقیت ثبت شد");
|
||||
setIsAddingHealthLog(false);
|
||||
setLogForm({
|
||||
appetite: "عالی",
|
||||
energy: "نرمال",
|
||||
digestion: "نرمال",
|
||||
note: ""
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReminderSubmit = () => {
|
||||
if (activePet) {
|
||||
if (!reminderForm.title) {
|
||||
toast.error("لطفاً عنوان یادآور را وارد کنید");
|
||||
return;
|
||||
}
|
||||
addReminder(activePet.id, reminderForm);
|
||||
toast.success("یادآور با موفقیت ثبت شد");
|
||||
setIsAddingReminder(false);
|
||||
setReminderForm({
|
||||
title: "",
|
||||
time: "08:00",
|
||||
frequency: "روزانه",
|
||||
productId: ""
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReminder = (reminder: Reminder) => {
|
||||
if (activePet) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
toggleReminder(activePet.id, reminder.id, today);
|
||||
|
||||
const isCompleting = !reminder.completedDates.includes(today);
|
||||
if (isCompleting) {
|
||||
toast.success(`دوز ${reminder.title} تایید شد`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
if (activePet) {
|
||||
setFormData({
|
||||
@ -256,6 +249,14 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && view === "detail" && activePet) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-20" dir="rtl">
|
||||
<PetProfileSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Pet Index Page
|
||||
if (view === "index") {
|
||||
return (
|
||||
|
||||
35
frontend/application/components/TickerBanner.tsx
Normal file
35
frontend/application/components/TickerBanner.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
export default function TickerBanner() {
|
||||
const getText = useSettingsStore((state) => state.getText);
|
||||
const announcementText = getText(
|
||||
"shipping_notice",
|
||||
"ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان — گارانتی اصالت کانینا آلمان — مشاوره تخصصی مکملهای دارویی سگ و گربه با کادر دامپزشکان مجرب"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-canina-gold text-canina-dark text-xs font-black py-1.5 overflow-hidden relative shadow-xs border-b border-amber-300/40 select-none">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap animate-marquee">
|
||||
<span className="flex items-center gap-2 px-4">
|
||||
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
|
||||
{announcementText}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 px-4">
|
||||
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
|
||||
{announcementText}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 px-4">
|
||||
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
|
||||
{announcementText}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 px-4">
|
||||
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
|
||||
{announcementText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "motion/react";
|
||||
import { X, CreditCard, Sparkles, CheckCircle2, TrendingUp, DollarSign } from "lucide-react";
|
||||
import { toPersian, cn } from "../lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useUserStore } from "../lib/store/userStore";
|
||||
|
||||
interface TopUpModalProps {
|
||||
isOpen: boolean;
|
||||
@ -37,6 +38,10 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!useUserStore.getState().isLoggedIn) {
|
||||
toast.error("جهت افزایش موجودی کیف پول لطفاً ابتدا وارد حساب کاربری خود شوید.");
|
||||
return;
|
||||
}
|
||||
const finalAmount = parseInt(amount);
|
||||
if (isNaN(finalAmount) || finalAmount < 10000) {
|
||||
toast.error("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است");
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react";
|
||||
import { OrderRowSkeleton } from "./Skeleton";
|
||||
import { useUserStore, Address } from "../lib/store/userStore";
|
||||
import { useCartStore } from "../lib/store/cartStore";
|
||||
import { toPersian, cn } from "../lib/utils";
|
||||
import { toPersian, toEnglishDigits, cn } from "../lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import OrderDetailsModal from "./OrderDetailsModal";
|
||||
import AddressModal from "./AddressModal";
|
||||
@ -16,7 +16,15 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function UserDashboard() {
|
||||
const router = useRouter();
|
||||
const { profile, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
||||
const { profile, isLoggedIn, logout, updateProfile, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLoggedIn && typeof window !== "undefined") {
|
||||
router.push("/");
|
||||
toast.error("برای دسترسی به داشبورد لطفاً ابتدا وارد حساب کاربری خود شوید.");
|
||||
}
|
||||
}, [isLoggedIn, router]);
|
||||
|
||||
const { orders } = useCartStore();
|
||||
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
|
||||
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
||||
@ -24,8 +32,16 @@ export default function UserDashboard() {
|
||||
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
|
||||
React.useEffect(() => {
|
||||
if (activeTab === "orders") {
|
||||
setIsLoadingOrders(true);
|
||||
useUserStore.getState().fetchProfile().finally(() => setIsLoadingOrders(false));
|
||||
let isSubscribed = true;
|
||||
Promise.resolve().then(() => {
|
||||
if (isSubscribed) setIsLoadingOrders(true);
|
||||
});
|
||||
useUserStore.getState().fetchProfile().finally(() => {
|
||||
if (isSubscribed) setIsLoadingOrders(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}
|
||||
}, [activeTab]);
|
||||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||
@ -42,36 +58,41 @@ export default function UserDashboard() {
|
||||
// Profile State
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
const [profileForm, setProfileForm] = useState(() => ({
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || "",
|
||||
mobile: profile.mobile || ""
|
||||
});
|
||||
}));
|
||||
|
||||
// Sync profile data on change
|
||||
React.useEffect(() => {
|
||||
setProfileForm({
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || "",
|
||||
mobile: profile.mobile || ""
|
||||
let isSubscribed = true;
|
||||
Promise.resolve().then(() => {
|
||||
if (isSubscribed) {
|
||||
setProfileForm({
|
||||
firstName: profile.firstName || "",
|
||||
lastName: profile.lastName || "",
|
||||
email: profile.email || "",
|
||||
mobile: profile.mobile || ""
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isEditing) return;
|
||||
|
||||
const cleanPhone = profileForm.mobile.trim();
|
||||
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||
toast.error("شماره موبایل وارد شده معتبر نیست (باید ۱۱ رقم باشد و با ۰۹ شروع شود)");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
await useUserStore.getState().updateProfile({
|
||||
const cleanPhone = toEnglishDigits(profileForm.mobile);
|
||||
await updateProfile({
|
||||
firstName: profileForm.firstName,
|
||||
lastName: profileForm.lastName,
|
||||
email: profileForm.email,
|
||||
@ -79,8 +100,8 @@ export default function UserDashboard() {
|
||||
});
|
||||
setIsEditing(false);
|
||||
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "خطا در ویرایش اطلاعات");
|
||||
} catch {
|
||||
toast.error("خطا در ویرایش اطلاعات");
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@ -98,7 +119,7 @@ export default function UserDashboard() {
|
||||
} else {
|
||||
await addAddress(addr);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error("خطا در ثبت آدرس");
|
||||
}
|
||||
setEditingAddress(null);
|
||||
@ -114,7 +135,7 @@ export default function UserDashboard() {
|
||||
try {
|
||||
await deleteAddress(addressToDelete.id);
|
||||
toast.success("آدرس با موفقیت حذف شد");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error("خطا در حذف آدرس");
|
||||
}
|
||||
setAddressToDelete(null);
|
||||
@ -357,7 +378,7 @@ export default function UserDashboard() {
|
||||
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors italic">سفارش {toPersian(order.trackingNumber || order.id?.substring(0, 8))}</div>
|
||||
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 opacity-30" />
|
||||
{toPersian(new Date(order.date || (order as any).createdAt).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as any).createdAt).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}
|
||||
{toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -464,7 +485,7 @@ export default function UserDashboard() {
|
||||
try {
|
||||
await setDefaultAddress(addr.id);
|
||||
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error("خطا در تغییر آدرس پیشفرض");
|
||||
}
|
||||
}}
|
||||
@ -514,18 +535,28 @@ export default function UserDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full md:w-auto">
|
||||
<div className="relative group/btn flex-1 md:flex-none">
|
||||
<button
|
||||
disabled
|
||||
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 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
برداشت وجه
|
||||
</button>
|
||||
<div className="absolute top-full right-0 mt-3 w-48 p-3 bg-medical-gray-900 text-white text-[10px] font-bold rounded-xl opacity-0 group-hover/btn:opacity-100 transition-all pointer-events-none z-20 text-center shadow-xl">
|
||||
قابلیت برداشت وجه بهزودی فعال خواهد شد.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (profile.walletBalance < 50000) {
|
||||
toast.error("حداقل موجودی برای ثبت درخواست برداشت ۵۰,۰۰۰ تومان است.");
|
||||
return;
|
||||
}
|
||||
const withdrawAmountStr = prompt("لطفاً مبلغ مورد نظر برای برداشت (تومان) را وارد کنید:", "100000");
|
||||
if (!withdrawAmountStr) return;
|
||||
const withdrawAmount = parseInt(withdrawAmountStr);
|
||||
if (isNaN(withdrawAmount) || withdrawAmount > profile.walletBalance) {
|
||||
toast.error("مبلغ وارد شده معتبر نیست یا از موجودی کیف پول بیشتر است.");
|
||||
return;
|
||||
}
|
||||
const iban = prompt("لطفاً شماره شبا (IR...) جهت واریز را وارد کنید:");
|
||||
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"
|
||||
>
|
||||
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
برداشت وجه
|
||||
</button>
|
||||
<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"
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { Play, PlayCircle, Star, ShieldCheck, X } from "lucide-react";
|
||||
import { Play, Star, ShieldCheck, X } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import SafeImage from "./SafeImage";
|
||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||
import { videoService, Video } from "../lib/services/videoService";
|
||||
|
||||
export interface TestimonialItem {
|
||||
id?: string;
|
||||
clinicName?: string;
|
||||
vetName?: string;
|
||||
imageUrl?: string;
|
||||
quote?: string;
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
videoUrl?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_VIDEOS = [
|
||||
{
|
||||
id: "v1",
|
||||
@ -38,11 +49,10 @@ const FALLBACK_VIDEOS = [
|
||||
}
|
||||
];
|
||||
|
||||
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
|
||||
const router = useRouter();
|
||||
export default function VetGallery({ testimonials = [] }: { testimonials?: TestimonialItem[] }) {
|
||||
const getText = useSettingsStore(state => state.getText);
|
||||
const [apiVideos, setApiVideos] = useState<Video[]>([]);
|
||||
const [selectedVideo, setSelectedVideo] = useState<any>(null);
|
||||
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
|
||||
@ -55,13 +65,13 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
||||
const displayItems = testimonials.length > 0
|
||||
? testimonials.map((t, idx) => ({
|
||||
id: t.id || `t-${idx}`,
|
||||
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : t.vetName,
|
||||
doctor: t.vetName,
|
||||
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : (t.vetName || ''),
|
||||
doctor: t.vetName || '',
|
||||
duration: "۰۲:۰۰",
|
||||
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||
description: t.quote,
|
||||
quote: t.quote
|
||||
description: t.quote || '',
|
||||
quote: t.quote || ''
|
||||
}))
|
||||
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
||||
|
||||
@ -102,7 +112,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
||||
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
|
||||
<SafeImage
|
||||
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
|
||||
alt={video.title || video.vetName}
|
||||
alt={video.title || video.vetName || 'ویدیو دامپزشک'}
|
||||
className="w-full h-full"
|
||||
imgClassName="object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
|
||||
/>
|
||||
@ -117,7 +127,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-lg font-black text-medical-gray-900 mb-2 group-hover:text-canina-blue transition-colors leading-tight font-vazir">
|
||||
{video.title || video.quote?.substring(0, 40) + '...'}
|
||||
{video.title || (video.quote ? video.quote.substring(0, 40) + '...' : '')}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 text-medical-gray-500 font-bold text-sm font-vazir">
|
||||
<ShieldCheck className="w-4 h-4 text-canina-blue" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||
import { ChevronRight, PlayCircle, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
|
||||
|
||||
@ -3,14 +3,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import CartDrawer from '../CartDrawer';
|
||||
import { useCartStore } from '../../store/cartStore';
|
||||
import { productService } from '../../services/productService';
|
||||
import { useCartStore } from '../../lib/store/cartStore';
|
||||
import { productService } from '../../lib/services/productService';
|
||||
|
||||
vi.mock('../../store/cartStore', () => ({
|
||||
vi.mock('../../lib/store/cartStore', () => ({
|
||||
useCartStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/productService', () => ({
|
||||
vi.mock('../../lib/services/productService', () => ({
|
||||
productService: {
|
||||
getProducts: vi.fn(),
|
||||
},
|
||||
@ -28,7 +28,7 @@ const mockProduct = {
|
||||
describe('CartDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(productService.getProducts).mockResolvedValue([]);
|
||||
vi.mocked(productService.getProducts).mockResolvedValue({ data: [] } as any);
|
||||
});
|
||||
|
||||
it('renders empty cart state when no items in cart', () => {
|
||||
@ -44,7 +44,7 @@ describe('CartDrawer', () => {
|
||||
coupon: null,
|
||||
applyCoupon: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useCartStore>);
|
||||
|
||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||
|
||||
@ -64,7 +64,7 @@ describe('CartDrawer', () => {
|
||||
coupon: null,
|
||||
applyCoupon: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useCartStore>);
|
||||
|
||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||
|
||||
@ -86,7 +86,7 @@ describe('CartDrawer', () => {
|
||||
coupon: null,
|
||||
applyCoupon: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useCartStore>);
|
||||
|
||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||
|
||||
|
||||
@ -3,16 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import FeaturedProducts from '../FeaturedProducts';
|
||||
import { productService } from '../../services/productService';
|
||||
import { usePetStore } from '../../store/usePetStore';
|
||||
import { productService } from '../../lib/services/productService';
|
||||
import { usePetStore } from '../../lib/store/usePetStore';
|
||||
|
||||
vi.mock('../../services/productService', () => ({
|
||||
vi.mock('../../lib/services/productService', () => ({
|
||||
productService: {
|
||||
getFeaturedProducts: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store/usePetStore', () => ({
|
||||
vi.mock('../../lib/store/usePetStore', () => ({
|
||||
usePetStore: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -37,7 +37,7 @@ describe('FeaturedProducts', () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(usePetStore).mockReturnValue({
|
||||
getActivePet: () => null,
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof usePetStore>);
|
||||
});
|
||||
|
||||
it('renders loading skeletons initially', () => {
|
||||
@ -48,7 +48,7 @@ describe('FeaturedProducts', () => {
|
||||
});
|
||||
|
||||
it('renders products once loaded', async () => {
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@ -59,7 +59,7 @@ describe('FeaturedProducts', () => {
|
||||
|
||||
it('calls onProductClick when product card is clicked', async () => {
|
||||
const handleProductClick = vi.fn();
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@ -72,7 +72,7 @@ describe('FeaturedProducts', () => {
|
||||
|
||||
it('calls onShopNavigate when navigation link is clicked', async () => {
|
||||
const handleShopNavigate = vi.fn();
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
||||
|
||||
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
||||
|
||||
@ -3,19 +3,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Header from '../Header';
|
||||
import { useCartStore } from '../../store/cartStore';
|
||||
import { usePetStore } from '../../store/usePetStore';
|
||||
import { useUserStore } from '../../store/userStore';
|
||||
import { useCartStore } from '../../lib/store/cartStore';
|
||||
import { usePetStore } from '../../lib/store/usePetStore';
|
||||
import { useUserStore } from '../../lib/store/userStore';
|
||||
|
||||
vi.mock('../../store/cartStore', () => ({
|
||||
vi.mock('../../lib/store/cartStore', () => ({
|
||||
useCartStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../store/usePetStore', () => ({
|
||||
vi.mock('../../lib/store/usePetStore', () => ({
|
||||
usePetStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../store/userStore', () => ({
|
||||
vi.mock('../../lib/store/userStore', () => ({
|
||||
useUserStore: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -25,21 +25,21 @@ describe('Header', () => {
|
||||
|
||||
vi.mocked(useCartStore).mockReturnValue({
|
||||
getTotalItems: () => 3,
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useCartStore>);
|
||||
|
||||
vi.mocked(usePetStore).mockReturnValue({
|
||||
pets: [],
|
||||
activePetId: null,
|
||||
setActivePet: vi.fn(),
|
||||
getActivePet: () => null,
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof usePetStore>);
|
||||
|
||||
vi.mocked(useUserStore).mockReturnValue({
|
||||
role: 'User_Guest',
|
||||
isLoggedIn: false,
|
||||
logout: vi.fn(),
|
||||
profile: { firstName: '' },
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useUserStore>);
|
||||
});
|
||||
|
||||
it('renders brand name and login button when guest', () => {
|
||||
@ -65,14 +65,14 @@ describe('Header', () => {
|
||||
isLoggedIn: true,
|
||||
logout: vi.fn(),
|
||||
profile: { firstName: 'کوروش' },
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof useUserStore>);
|
||||
|
||||
vi.mocked(usePetStore).mockReturnValue({
|
||||
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
||||
activePetId: 'pet-1',
|
||||
setActivePet: vi.fn(),
|
||||
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
||||
} as any);
|
||||
} as unknown as ReturnType<typeof usePetStore>);
|
||||
|
||||
render(
|
||||
<Header
|
||||
|
||||
@ -3,9 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Hero from '../Hero';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useSettingsStore } from '../../lib/store/settingsStore';
|
||||
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
vi.mock('../../lib/store/settingsStore', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -21,7 +21,7 @@ describe('Hero', () => {
|
||||
return fallback;
|
||||
});
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
|
||||
|
||||
render(<Hero onShopNavigate={vi.fn()} />);
|
||||
|
||||
@ -31,8 +31,8 @@ describe('Hero', () => {
|
||||
});
|
||||
|
||||
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
||||
const mockGetText = vi.fn().mockImplementation((key, fallback) => fallback);
|
||||
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||
const mockGetText = vi.fn().mockImplementation((_key, fallback) => fallback);
|
||||
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
|
||||
|
||||
const handleShopNavigate = vi.fn();
|
||||
render(<Hero onShopNavigate={handleShopNavigate} />);
|
||||
|
||||
@ -3,9 +3,9 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useSettingsStore } from '../../lib/store/settingsStore';
|
||||
|
||||
vi.mock('../../store/settingsStore', () => ({
|
||||
vi.mock('../../lib/store/settingsStore', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@ -756,7 +756,7 @@ export const PRODUCTS: Product[] = [
|
||||
contraindications: ["مصرف همزمان ویتامین D2 مجاز نیست"],
|
||||
calculateDosage: (weight, isPregnant) => {
|
||||
const rate = isPregnant ? 3 : 2;
|
||||
let qty = (weight / 10) * rate;
|
||||
const qty = (weight / 10) * rate;
|
||||
return { quantity: Math.round(qty), unit: "قرص", description: isPregnant ? "دوز بارداری/شیردهی" : "دوز معمول" };
|
||||
},
|
||||
analysis: { "کلسیم": "۲۲٪" },
|
||||
@ -788,7 +788,7 @@ export const PRODUCTS: Product[] = [
|
||||
symptoms: ["خستگی مزمن", "رشد نامتناسب", "استرس"],
|
||||
suitableFor: "هر دو",
|
||||
calculateDosage: (weight) => {
|
||||
let qty = Math.max(1, weight / 10);
|
||||
const qty = Math.max(1, weight / 10);
|
||||
return { quantity: Math.round(qty), unit: "قرص", description: "روزانه" };
|
||||
},
|
||||
analysis: { "ویتامینها": "A تا K3 کامل" },
|
||||
@ -853,7 +853,7 @@ export const PRODUCTS: Product[] = [
|
||||
symptoms: ["بینی روشن", "پوشش کدر", "کمبود ید"],
|
||||
suitableFor: "هر دو",
|
||||
calculateDosage: (weight) => {
|
||||
let tablets = (weight / 10) * 2;
|
||||
const tablets = (weight / 10) * 2;
|
||||
return { quantity: Math.round(tablets), unit: "قرص", description: "روزانه" };
|
||||
},
|
||||
analysis: { "ید": "غنی", "لیزین": "+" },
|
||||
@ -1024,7 +1024,7 @@ export const PRODUCTS: Product[] = [
|
||||
suitableFor: "گربه",
|
||||
onSetOfAction: "هفته دوم",
|
||||
calculateDosage: (weight) => {
|
||||
let tsp = weight > 6 ? 1 : 0.5;
|
||||
const tsp = weight > 6 ? 1 : 0.5;
|
||||
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
|
||||
},
|
||||
analysis: { "تورین": "۷۰۰ گرم/kg" },
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useNetworkStatus() {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [isOnline, setIsOnline] = useState(() =>
|
||||
typeof navigator !== 'undefined' ? navigator.onLine : true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
||||
setIsOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useUserStore } from '../store/userStore';
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
||||
|
||||
@ -8,7 +9,7 @@ export interface ApiErrorPayload {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
}
|
||||
@ -45,20 +46,28 @@ api.interceptors.response.use(
|
||||
const status = error.response?.status;
|
||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||
|
||||
// Handle 401 Unauthorized globally
|
||||
if (status === 401 || status === 403) {
|
||||
// Handle 401 Unauthorized globally - trigger Auth Modal
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
try {
|
||||
useUserStore.getState().logout();
|
||||
useUserStore.getState().setAuthModalOpen(true);
|
||||
} catch {
|
||||
// Ignore if store not ready
|
||||
}
|
||||
} else if (status === 403) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
try {
|
||||
const { useUserStore } = require('../store/userStore');
|
||||
useUserStore.getState().logout();
|
||||
} catch {
|
||||
// Ignore if store not ready
|
||||
}
|
||||
}
|
||||
|
||||
// Show formatted Farsi toast if not explicitly suppressed
|
||||
if (!error.config?.hideErrorToast) {
|
||||
// Show formatted Farsi toast if not explicitly suppressed and not 404
|
||||
if (!error.config?.hideErrorToast && status !== 404) {
|
||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||
const firstDetailMessage = responseData.details[0]?.message || farsiMessage;
|
||||
toast.error(firstDetailMessage);
|
||||
|
||||
@ -11,8 +11,8 @@ export interface User {
|
||||
charityDonationTotal: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pets?: any[];
|
||||
orders?: any[];
|
||||
pets?: unknown[];
|
||||
orders?: unknown[];
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@ -23,6 +23,15 @@ export interface AuthResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiErr {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
message?: string | string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private static instance: AuthService;
|
||||
|
||||
@ -42,8 +51,9 @@ export class AuthService {
|
||||
try {
|
||||
const response = await api.post('/auth/send-otp', { phoneNumber });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در ارسال کد تایید';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در ارسال کد تایید';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -59,8 +69,9 @@ export class AuthService {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'کد تایید نامعتبر است';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'کد تایید نامعتبر است';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -68,7 +79,7 @@ export class AuthService {
|
||||
/**
|
||||
* Register with email/mobile and password
|
||||
*/
|
||||
public async register(data: any): Promise<AuthResponse> {
|
||||
public async register(data: Record<string, unknown>): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await api.post('/auth/register', data);
|
||||
const { data: resData } = response.data;
|
||||
@ -76,8 +87,9 @@ export class AuthService {
|
||||
localStorage.setItem('accessToken', resData.accessToken);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در ثبتنام';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در ثبتنام';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -93,8 +105,9 @@ export class AuthService {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
}
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -106,11 +119,12 @@ export class AuthService {
|
||||
try {
|
||||
const response = await api.get('/users/profile');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
if (err.response?.status === 401 || err.response?.status === 403) {
|
||||
return null;
|
||||
}
|
||||
const message = error.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
||||
const message = err.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -122,8 +136,9 @@ export class AuthService {
|
||||
try {
|
||||
const response = await api.patch('/users/profile', profileData);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import api from './api';
|
||||
import { CartItem } from '../store/cartStore';
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
orderId: string;
|
||||
productId: string | null;
|
||||
quantity: number;
|
||||
product?: any;
|
||||
product?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
@ -21,6 +20,14 @@ export interface Order {
|
||||
orderItems: OrderItem[];
|
||||
}
|
||||
|
||||
interface ApiErr {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class OrderService {
|
||||
private static instance: OrderService;
|
||||
|
||||
@ -50,8 +57,9 @@ export class OrderService {
|
||||
try {
|
||||
const response = await api.post('/orders', orderData);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در ثبت سفارش';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در ثبت سفارش';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -63,8 +71,9 @@ export class OrderService {
|
||||
try {
|
||||
const response = await api.get('/orders');
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
@ -76,8 +85,9 @@ export class OrderService {
|
||||
try {
|
||||
const response = await api.get(`/orders/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
||||
} catch (error) {
|
||||
const err = error as ApiErr;
|
||||
const message = err.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,18 +23,19 @@ export class ProductService {
|
||||
return ProductService.instance;
|
||||
}
|
||||
|
||||
private mapBackendToFrontend(data: any): Product {
|
||||
private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
|
||||
const data = inputData as any;
|
||||
// Find the local static product to inherit functions like calculateDosage
|
||||
const local = PRODUCTS.find(p =>
|
||||
p.artNo === data.artNo ||
|
||||
p.id === data.productGroup ||
|
||||
(data.slug && data.slug.includes(p.id))
|
||||
(typeof data.slug === 'string' && data.slug.includes(p.id))
|
||||
);
|
||||
|
||||
const safeParse = (val: any, fallback: any) => {
|
||||
const safeParse = (val: unknown, fallback: unknown) => {
|
||||
if (!val) return fallback;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||||
try { return JSON.parse(val); } catch { return fallback; }
|
||||
}
|
||||
return val;
|
||||
};
|
||||
@ -68,8 +69,8 @@ export class ProductService {
|
||||
? data.imageUrl
|
||||
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
|
||||
|
||||
main_ingredients: data.ingredientList?.map((i: any) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
||||
symptoms: data.symptoms?.map((s: any) => s.symptom || s) || [],
|
||||
main_ingredients: data.ingredientList?.map((i: { ingredient: string }) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
||||
symptoms: data.symptoms?.map((s: { symptom?: string }) => s.symptom || s) || [],
|
||||
keyBenefits: safeParse(data.keyBenefits, []),
|
||||
expectedResults: safeParse(data.expectedResults, []),
|
||||
benefitsList: safeParse(data.benefitsList, []),
|
||||
@ -90,19 +91,23 @@ export class ProductService {
|
||||
symptom?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}): Promise<{ data: Product[]; meta: { total: number; page: number; lastPage: number; limit: number } }> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
|
||||
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
|
||||
if (filters?.query) params.append('search', filters.query);
|
||||
if (filters?.symptom) params.append('symptom', filters.symptom);
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
params.append('page', String(filters?.page || 1));
|
||||
params.append('limit', String(filters?.limit || 12));
|
||||
|
||||
try {
|
||||
const response = await api.get(`/products?${params.toString()}`);
|
||||
return {
|
||||
data: response.data.data.map((item: any) => this.mapBackendToFrontend(item)),
|
||||
data: response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
|
||||
meta: response.data.meta,
|
||||
};
|
||||
} catch (error) {
|
||||
@ -134,7 +139,7 @@ export class ProductService {
|
||||
public async getFeaturedProducts(): Promise<Product[]> {
|
||||
try {
|
||||
const response = await api.get('/products?limit=30');
|
||||
const all = response.data.data.map((item: any) => this.mapBackendToFrontend(item));
|
||||
const all = response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item));
|
||||
const featured: Product[] = [];
|
||||
const categoriesSeen = new Set<string>();
|
||||
|
||||
|
||||
@ -32,11 +32,11 @@ const mockProduct = {
|
||||
dosage_logic: '1',
|
||||
benefits: '',
|
||||
symptoms: [],
|
||||
suitableFor: 'سگ' as any,
|
||||
suitableFor: 'سگ' as never,
|
||||
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
||||
analysis: {},
|
||||
feedingAdvice: '',
|
||||
specialist: null as any,
|
||||
specialist: null as never,
|
||||
image: '',
|
||||
};
|
||||
|
||||
|
||||
@ -79,6 +79,7 @@ describe('userStore', () => {
|
||||
|
||||
vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData);
|
||||
|
||||
(globalThis as any)._testToken = 'test-token';
|
||||
const store = useUserStore.getState();
|
||||
await store.fetchProfile();
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ interface CartStore {
|
||||
addOrder: (orderData: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
|
||||
clearCart: () => void;
|
||||
removeCoupon: () => void;
|
||||
setOrders: (orders: any[]) => void;
|
||||
setOrders: (orders: Record<string, unknown>[]) => void;
|
||||
getTotalItems: () => number;
|
||||
getSubtotal: () => number;
|
||||
getDiscount: () => number;
|
||||
@ -120,6 +120,7 @@ export const useCartStore = create<CartStore>()(
|
||||
|
||||
try {
|
||||
const backendOrder = await orderService.createOrder(payload);
|
||||
const bo = backendOrder as unknown as Record<string, unknown>;
|
||||
|
||||
const newOrder: Order = {
|
||||
...orderData,
|
||||
@ -127,15 +128,15 @@ export const useCartStore = create<CartStore>()(
|
||||
date: backendOrder.createdAt,
|
||||
total: Number(backendOrder.totalAmount),
|
||||
charityDonation: Number(backendOrder.charityDonation),
|
||||
status: backendOrder.status as any,
|
||||
isRefill: (backendOrder as any).isRefill !== undefined ? Boolean((backendOrder as any).isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
||||
paymentMethod: (backendOrder as any).paymentMethod || orderData.paymentMethod,
|
||||
status: (['processing', 'shipped', 'delivered'].includes(backendOrder.status) ? backendOrder.status : 'processing') as Order['status'],
|
||||
isRefill: bo.isRefill !== undefined ? Boolean(bo.isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
||||
paymentMethod: (bo.paymentMethod as string) || orderData.paymentMethod,
|
||||
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||
};
|
||||
|
||||
set({ orders: [newOrder, ...get().orders] });
|
||||
return backendOrder.id;
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("Order creation failed on backend:", error);
|
||||
throw error;
|
||||
}
|
||||
@ -144,20 +145,20 @@ export const useCartStore = create<CartStore>()(
|
||||
removeCoupon: () => set({ coupon: null }),
|
||||
setOrders: (backendOrders) => {
|
||||
const mappedOrders: Order[] = backendOrders.map(bo => ({
|
||||
id: bo.id,
|
||||
date: bo.createdAt,
|
||||
items: bo.orderItems?.map((oi: any) => ({
|
||||
id: String(bo.id),
|
||||
date: String(bo.createdAt),
|
||||
items: (bo.orderItems as Array<{ product: Product; quantity: number }>)?.map((oi) => ({
|
||||
product: oi.product,
|
||||
quantity: oi.quantity
|
||||
})) || [],
|
||||
total: Number(bo.totalAmount),
|
||||
charityDonation: Number(bo.charityDonation),
|
||||
status: bo.status,
|
||||
isRefill: Boolean((bo as any).isRefill),
|
||||
paymentMethod: (bo as any).paymentMethod,
|
||||
petId: bo.petId || bo.pet?.id,
|
||||
shippingAddress: bo.shippingAddress,
|
||||
trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||
status: (['processing', 'shipped', 'delivered'].includes(String(bo.status)) ? bo.status : 'processing') as Order['status'],
|
||||
isRefill: Boolean(bo.isRefill),
|
||||
paymentMethod: bo.paymentMethod as string | undefined,
|
||||
petId: (bo.petId as string) || ((bo.pet as { id?: string })?.id),
|
||||
shippingAddress: bo.shippingAddress as string | undefined,
|
||||
trackingNumber: (bo.trackingNumber as string) || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||
}));
|
||||
set({ orders: mappedOrders });
|
||||
},
|
||||
|
||||
@ -4,9 +4,9 @@ interface UIState {
|
||||
isCartOpen: boolean;
|
||||
isLoginModalOpen: boolean;
|
||||
isB2BPortalOpen: boolean;
|
||||
advisorData: any;
|
||||
advisorData: Record<string, unknown> | null;
|
||||
setCartOpen: (open: boolean) => void;
|
||||
setLoginModalOpen: (open: boolean, advisorData?: any) => void;
|
||||
setLoginModalOpen: (open: boolean, advisorData?: Record<string, unknown> | null) => void;
|
||||
setB2BPortalOpen: (open: boolean) => void;
|
||||
clearAdvisorData: () => void;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import api from "../services/api";
|
||||
import { toast } from "sonner";
|
||||
import { useUserStore } from "./userStore";
|
||||
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
@ -54,7 +55,7 @@ interface PetStore {
|
||||
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
|
||||
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
|
||||
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
|
||||
setPets: (pets: any[]) => void;
|
||||
setPets: (pets: Record<string, unknown>[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@ -125,7 +126,6 @@ export const usePetStore = create<PetStore>()(
|
||||
getActivePet: () => {
|
||||
const { pets, activePetId } = get();
|
||||
try {
|
||||
const { useUserStore } = require("./userStore");
|
||||
if (!useUserStore.getState().isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
@ -195,29 +195,29 @@ export const usePetStore = create<PetStore>()(
|
||||
const mappedPets: PetProfile[] = backendPets.map(bp => {
|
||||
const existingLocal = currentLocalPets.find(lp => lp.id === bp.id);
|
||||
return {
|
||||
id: bp.id,
|
||||
name: bp.name,
|
||||
type: bp.type as any,
|
||||
breed: bp.breed,
|
||||
age: Number(bp.age),
|
||||
weight: Number(bp.weight),
|
||||
activityLevel: bp.activityLevel as any,
|
||||
medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [],
|
||||
image: bp.imageUrl || undefined,
|
||||
reminders: bp.reminders?.map((r: any) => ({
|
||||
id: String(bp.id),
|
||||
name: String(bp.name),
|
||||
type: (bp.type as PetProfile['type']) || "سگ",
|
||||
breed: String(bp.breed || ''),
|
||||
age: Number(bp.age || 0),
|
||||
weight: Number(bp.weight || 0),
|
||||
activityLevel: (bp.activityLevel as PetProfile['activityLevel']) || "متوسط",
|
||||
medicalConditions: (bp.medicalConditions as Array<{ condition: string }>)?.map((mc) => mc.condition) || [],
|
||||
image: (bp.imageUrl as string) || undefined,
|
||||
reminders: (bp.reminders as Array<{ id: string; title: string; time: string; frequency: Reminder['frequency']; productId?: string; completions?: Array<{ completedDate: string }> }>)?.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
time: r.time,
|
||||
frequency: r.frequency as any,
|
||||
frequency: r.frequency,
|
||||
productId: r.productId,
|
||||
completedDates: r.completions?.map((c: any) => c.completedDate) || []
|
||||
completedDates: r.completions?.map((c) => c.completedDate) || []
|
||||
})) || [],
|
||||
logs: bp.healthLogs?.map((hl: any) => ({
|
||||
logs: (bp.healthLogs as Array<{ id: string; loggedDate: string; appetite: HealthLog['appetite']; energy: HealthLog['energy']; digestion: HealthLog['digestion']; note?: string }>)?.map((hl) => ({
|
||||
id: hl.id,
|
||||
date: hl.loggedDate,
|
||||
appetite: hl.appetite as any,
|
||||
energy: hl.energy as any,
|
||||
digestion: hl.digestion as any,
|
||||
appetite: hl.appetite,
|
||||
energy: hl.energy,
|
||||
digestion: hl.digestion,
|
||||
note: hl.note || undefined
|
||||
})) || [],
|
||||
consumptions: existingLocal?.consumptions || []
|
||||
|
||||
@ -44,9 +44,11 @@ interface UserProfile {
|
||||
interface UserStore {
|
||||
role: UserRole;
|
||||
isLoggedIn: boolean;
|
||||
isAuthModalOpen: boolean;
|
||||
profile: UserProfile;
|
||||
setRole: (role: UserRole) => void;
|
||||
setLoggedIn: (isLoggedIn: boolean) => void;
|
||||
setAuthModalOpen: (open: boolean) => void;
|
||||
updateProfile: (profile: Partial<UserProfile>) => Promise<void>;
|
||||
addAddress: (address: Address) => Promise<void>;
|
||||
updateAddress: (id: string, address: Address) => Promise<void>;
|
||||
@ -62,6 +64,7 @@ export const useUserStore = create<UserStore>()(
|
||||
(set) => ({
|
||||
role: "User_Guest",
|
||||
isLoggedIn: false,
|
||||
isAuthModalOpen: false,
|
||||
profile: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
@ -74,6 +77,7 @@ export const useUserStore = create<UserStore>()(
|
||||
},
|
||||
setRole: (role) => set({ role }),
|
||||
setLoggedIn: (isLoggedIn) => set({ isLoggedIn }),
|
||||
setAuthModalOpen: (open) => set({ isAuthModalOpen: open }),
|
||||
updateProfile: async (updates) => {
|
||||
try {
|
||||
const profileData = await authService.updateProfile(updates);
|
||||
@ -94,7 +98,16 @@ export const useUserStore = create<UserStore>()(
|
||||
addAddress: async (address) => {
|
||||
try {
|
||||
// Remove ID so backend generates UUID
|
||||
const { id, ...addressData } = address;
|
||||
const addressData = {
|
||||
title: address.title,
|
||||
receptorName: address.receptorName,
|
||||
phone: address.phone,
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
detail: address.detail,
|
||||
zipCode: address.zipCode,
|
||||
isDefault: address.isDefault,
|
||||
};
|
||||
await api.post('/users/addresses', addressData);
|
||||
await useUserStore.getState().fetchProfile();
|
||||
} catch (error) {
|
||||
@ -104,7 +117,16 @@ export const useUserStore = create<UserStore>()(
|
||||
},
|
||||
updateAddress: async (id, updated) => {
|
||||
try {
|
||||
const { id: _, ...addressData } = updated;
|
||||
const addressData = {
|
||||
title: updated.title,
|
||||
receptorName: updated.receptorName,
|
||||
phone: updated.phone,
|
||||
province: updated.province,
|
||||
city: updated.city,
|
||||
detail: updated.detail,
|
||||
zipCode: updated.zipCode,
|
||||
isDefault: updated.isDefault,
|
||||
};
|
||||
await api.patch(`/users/addresses/${id}`, addressData);
|
||||
await useUserStore.getState().fetchProfile();
|
||||
} catch (error) {
|
||||
@ -160,7 +182,7 @@ export const useUserStore = create<UserStore>()(
|
||||
},
|
||||
fetchProfile: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const token = typeof window !== 'undefined' && window.localStorage ? localStorage.getItem('accessToken') : (globalThis as any)._testToken;
|
||||
if (!token) {
|
||||
try { usePetStore.getState().reset(); } catch {}
|
||||
try { localStorage.removeItem("canina-pets"); } catch {}
|
||||
@ -201,14 +223,15 @@ export const useUserStore = create<UserStore>()(
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const pd = profileData as unknown as Record<string, unknown>;
|
||||
const backendWallet = Number(profileData.walletBalance || 0);
|
||||
const backendCharity = Number(profileData.charityDonationTotal || 0);
|
||||
const backendTransactions = (profileData as any).walletTransactions?.map((t: any) => ({
|
||||
const backendTransactions = (pd.walletTransactions as Array<{ id: string; type: string; amount: number; createdAt: string; status: string }>)?.map((t) => ({
|
||||
id: t.id,
|
||||
type: t.type === 'deposit' ? 'top_up' : 'purchase',
|
||||
type: (t.type === 'deposit' ? 'top_up' : 'purchase') as Transaction['type'],
|
||||
amount: Number(t.amount),
|
||||
date: t.createdAt,
|
||||
status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending'
|
||||
status: (t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending') as Transaction['status']
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
@ -221,7 +244,7 @@ export const useUserStore = create<UserStore>()(
|
||||
mobile: profileData.mobile || "",
|
||||
walletBalance: backendWallet,
|
||||
charityDonationTotal: backendCharity,
|
||||
addresses: (profileData as any).addresses?.length > 0 ? (profileData as any).addresses : state.profile.addresses,
|
||||
addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses,
|
||||
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
|
||||
}
|
||||
};
|
||||
@ -230,11 +253,11 @@ export const useUserStore = create<UserStore>()(
|
||||
// Sync orders list to CartStore while preserving local petId & address mappings
|
||||
if (profileData.orders) {
|
||||
const currentLocalOrders = useCartStore.getState().orders;
|
||||
const mergedOrders = profileData.orders.map((bo: any) => {
|
||||
const mergedOrders = (profileData.orders as Record<string, unknown>[]).map((bo) => {
|
||||
const localMatch = currentLocalOrders.find(lo => lo.id === bo.id);
|
||||
return {
|
||||
...bo,
|
||||
petId: bo.petId || bo.pet?.id || localMatch?.petId,
|
||||
petId: bo.petId || (bo.pet as { id?: string })?.id || localMatch?.petId,
|
||||
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
|
||||
};
|
||||
});
|
||||
@ -242,7 +265,7 @@ export const useUserStore = create<UserStore>()(
|
||||
}
|
||||
// Sync pets list to PetStore
|
||||
if (profileData.pets) {
|
||||
usePetStore.getState().setPets(profileData.pets);
|
||||
usePetStore.getState().setPets(profileData.pets as Record<string, unknown>[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user profile:", error);
|
||||
|
||||
@ -3,6 +3,12 @@ export const toPersian = (n: number | string | undefined | null) => {
|
||||
return n.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
||||
};
|
||||
|
||||
export const toEnglishDigits = (str: string) => {
|
||||
if (!str) return "";
|
||||
return str.replace(/[۰-۹]/g, d => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString());
|
||||
};
|
||||
|
||||
export function cn(...classes: (string | boolean | undefined)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
|
||||
1948
frontend/application/package-lock.json
generated
1948
frontend/application/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -20,12 +20,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "**/__tests__/**"]
|
||||
}
|
||||
|
||||
11
frontend/application/vitest.config.ts
Normal file
11
frontend/application/vitest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
},
|
||||
});
|
||||
25
frontend/application/vitest.setup.ts
Normal file
25
frontend/application/vitest.setup.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
});
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user