Compare commits
No commits in common. "b6453d05b9b96118f72b068806e6c28904a81496" and "49049a7f78775301f503035ba2d3680eb0ec9e0e" have entirely different histories.
b6453d05b9
...
49049a7f78
@ -70,10 +70,6 @@ model Media {
|
|||||||
url String @db.Text
|
url String @db.Text
|
||||||
mimetype String @db.VarChar(50)
|
mimetype String @db.VarChar(50)
|
||||||
size Int
|
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()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
@@map("media")
|
@@map("media")
|
||||||
@ -141,10 +137,6 @@ model Product {
|
|||||||
dosageLogic String? @map("dosage_logic") @db.Text
|
dosageLogic String? @map("dosage_logic") @db.Text
|
||||||
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
||||||
imageUrl String @map("image_url") @db.Text
|
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)
|
metaTitle String? @map("meta_title") @db.VarChar(200)
|
||||||
metaDescription String? @map("meta_description") @db.Text
|
metaDescription String? @map("meta_description") @db.Text
|
||||||
canonicalUrl String? @map("canonical_url") @db.Text
|
canonicalUrl String? @map("canonical_url") @db.Text
|
||||||
@ -166,18 +158,6 @@ model Product {
|
|||||||
@@map("products")
|
@@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 {
|
model ProductIngredient {
|
||||||
productId String @map("product_id") @db.Uuid
|
productId String @map("product_id") @db.Uuid
|
||||||
ingredient String @db.VarChar(150)
|
ingredient String @db.VarChar(150)
|
||||||
@ -411,30 +391,3 @@ model Video {
|
|||||||
|
|
||||||
@@map("videos")
|
@@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,36 +236,4 @@ export class AdminController {
|
|||||||
const settings = await this.adminService.updateSettings(data);
|
const settings = await this.adminService.updateSettings(data);
|
||||||
return { success: true, data: settings };
|
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,24 +36,11 @@ export class AdminService {
|
|||||||
todayVisits = 0;
|
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 {
|
return {
|
||||||
revenue,
|
revenue,
|
||||||
newOrders,
|
newOrders,
|
||||||
users,
|
users,
|
||||||
todayVisits: todayVisits || 154,
|
todayVisits,
|
||||||
categoriesDistribution: [
|
|
||||||
{ name: 'مکمل سگ', value: dogCount || 8 },
|
|
||||||
{ name: 'مکمل گربه', value: catCount || 6 },
|
|
||||||
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,14 +145,6 @@ export class AdminService {
|
|||||||
dosageLogic: data.dosageLogic,
|
dosageLogic: data.dosageLogic,
|
||||||
suitableFor: data.suitableFor,
|
suitableFor: data.suitableFor,
|
||||||
imageUrl: data.imageUrl,
|
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,
|
metaTitle: data.metaTitle,
|
||||||
metaDescription: data.metaDescription,
|
metaDescription: data.metaDescription,
|
||||||
keywords: data.keywords,
|
keywords: data.keywords,
|
||||||
@ -212,10 +191,6 @@ export class AdminService {
|
|||||||
dosageLogic: data.dosageLogic,
|
dosageLogic: data.dosageLogic,
|
||||||
suitableFor: data.suitableFor,
|
suitableFor: data.suitableFor,
|
||||||
imageUrl: data.imageUrl,
|
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,
|
metaTitle: data.metaTitle,
|
||||||
metaDescription: data.metaDescription,
|
metaDescription: data.metaDescription,
|
||||||
keywords: data.keywords,
|
keywords: data.keywords,
|
||||||
@ -414,8 +389,19 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
async getSettings() {
|
async getSettings() {
|
||||||
const settings = await this.prisma.uiText.findMany();
|
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', ... }
|
||||||
return settings.reduce(
|
return settings.reduce(
|
||||||
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
||||||
{},
|
{},
|
||||||
@ -435,37 +421,4 @@ export class AdminService {
|
|||||||
await this.prisma.$transaction(operations);
|
await this.prisma.$transaction(operations);
|
||||||
return this.getSettings();
|
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,8 +2,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
|
||||||
Body,
|
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@ -47,23 +45,4 @@ export class MediaController {
|
|||||||
const data = await this.mediaService.deleteMedia(id);
|
const data = await this.mediaService.deleteMedia(id);
|
||||||
return { success: true, data };
|
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,24 +61,4 @@ export class MediaService {
|
|||||||
await this.prisma.media.delete({ where: { id } });
|
await this.prisma.media.delete({ where: { id } });
|
||||||
return { success: true };
|
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,12 +20,10 @@ import { CmsModule } from './cms/cms.module';
|
|||||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||||
import { VideosModule } from './videos/videos.module';
|
import { VideosModule } from './videos/videos.module';
|
||||||
import { SmsModule } from './common/sms.module';
|
import { SmsModule } from './common/sms.module';
|
||||||
import { ContactModule } from './contact/contact.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SmsModule,
|
SmsModule,
|
||||||
ContactModule,
|
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
|
|||||||
@ -26,12 +26,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRoleLower = user.role.toLowerCase();
|
const hasRole = requiredRoles.includes(user.role);
|
||||||
const hasRole = requiredRoles.some(
|
|
||||||
(r) =>
|
|
||||||
r.toLowerCase() === userRoleLower ||
|
|
||||||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
|
|
||||||
);
|
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,15 +49,10 @@ export class CustomHttpExceptionFilter implements ExceptionFilter {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const rawMsg =
|
const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message;
|
||||||
typeof resObj.message === 'string'
|
|
||||||
? resObj.message
|
|
||||||
: exception.message;
|
|
||||||
message = this.translateGenericMessage(rawMsg, status);
|
message = this.translateGenericMessage(rawMsg, status);
|
||||||
const rawCode =
|
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||||
typeof resObj.code === 'string' ? resObj.code : undefined;
|
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||||
const rawError =
|
|
||||||
typeof resObj.error === 'string' ? resObj.error : undefined;
|
|
||||||
code = rawCode || this.deriveErrorCode(status, rawError);
|
code = rawCode || this.deriveErrorCode(status, rawError);
|
||||||
details = (resObj.details as Record<string, unknown>) || {};
|
details = (resObj.details as Record<string, unknown>) || {};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export interface SendPatternSmsOptions {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
private readonly logger = new Logger(SmsService.name);
|
||||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '9364100228';
|
private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
|
||||||
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,7 +20,7 @@ export class SmsService {
|
|||||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||||
if (!this.username || !this.password) {
|
if (!this.username || !this.password) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[SMS Simulated] MeliPayamak dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
`[SMS Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -80,13 +80,10 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send OTP Verification Code (Pattern 508079)
|
* Send OTP Verification Code
|
||||||
*/
|
*/
|
||||||
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
|
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
|
||||||
const bodyId = parseInt(
|
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
|
||||||
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
return this.sendPatternSms({
|
return this.sendPatternSms({
|
||||||
to: phone,
|
to: phone,
|
||||||
bodyId,
|
bodyId,
|
||||||
@ -95,17 +92,14 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send Order Confirmation SMS (Pattern 508081)
|
* Send Order Confirmation SMS
|
||||||
*/
|
*/
|
||||||
async sendOrderConfirmation(
|
async sendOrderConfirmation(
|
||||||
phone: string,
|
phone: string,
|
||||||
orderNumber: string,
|
orderNumber: string,
|
||||||
amount: string,
|
amount: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const bodyId = parseInt(
|
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
|
||||||
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
return this.sendPatternSms({
|
return this.sendPatternSms({
|
||||||
to: phone,
|
to: phone,
|
||||||
bodyId,
|
bodyId,
|
||||||
@ -114,7 +108,7 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send Shipping Status SMS with Tracking Code (Pattern 508082)
|
* Send Shipping Status SMS with Tracking Code
|
||||||
*/
|
*/
|
||||||
async sendShippingNotification(
|
async sendShippingNotification(
|
||||||
phone: string,
|
phone: string,
|
||||||
@ -122,7 +116,7 @@ export class SmsService {
|
|||||||
trackingCode: string,
|
trackingCode: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const bodyId = parseInt(
|
const bodyId = parseInt(
|
||||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
|
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
return this.sendPatternSms({
|
return this.sendPatternSms({
|
||||||
@ -132,24 +126,6 @@ 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
|
* Send Pet Care Vaccination / Deworming Reminder SMS
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
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,17 +90,13 @@ export class ProductsService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const isWholesaleOrAdmin =
|
const isWholesaleOrAdmin =
|
||||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
|
||||||
|
|
||||||
const data = rawProducts.map((p) => {
|
const data = rawProducts.map((p) => {
|
||||||
// Remove buyPrice for all non-admins
|
|
||||||
const { buyPrice, ...withoutBuyPrice } = p;
|
|
||||||
if (!isWholesaleOrAdmin) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
|
const { wholesalePrice, ...rest } = p;
|
||||||
return publicProduct;
|
return rest;
|
||||||
}
|
}
|
||||||
return isAdmin ? p : withoutBuyPrice;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -129,15 +125,12 @@ export class ProductsService {
|
|||||||
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
const isWholesaleOrAdmin =
|
const isWholesaleOrAdmin =
|
||||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
|
||||||
|
|
||||||
const { buyPrice, ...withoutBuyPrice } = product;
|
|
||||||
if (!isWholesaleOrAdmin) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
|
const { wholesalePrice, ...rest } = product;
|
||||||
return publicProduct;
|
return rest;
|
||||||
}
|
}
|
||||||
return isAdmin ? product : withoutBuyPrice;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveFilters() {
|
async getActiveFilters() {
|
||||||
|
|||||||
252
docs/backlog.md
252
docs/backlog.md
@ -1,252 +0,0 @@
|
|||||||
# 📋 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,7 +16,6 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^7.17.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^8.0.12",
|
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -34,7 +33,8 @@
|
|||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2"
|
"typescript-eslint": "^8.59.2",
|
||||||
|
"vite": "^8.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@ -294,6 +294,7 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -305,6 +306,7 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -315,6 +317,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -569,6 +572,7 @@
|
|||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -587,6 +591,7 @@
|
|||||||
"version": "0.133.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
@ -635,6 +640,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -651,6 +657,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -667,6 +674,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -683,6 +691,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -699,6 +708,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -715,6 +725,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -734,6 +745,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"musl"
|
"musl"
|
||||||
],
|
],
|
||||||
@ -753,6 +765,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -772,6 +785,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -791,6 +805,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -810,6 +825,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"musl"
|
"musl"
|
||||||
],
|
],
|
||||||
@ -829,6 +845,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -845,6 +862,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -863,6 +881,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -879,6 +898,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -892,6 +912,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
@ -1219,6 +1240,7 @@
|
|||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1313,7 +1335,7 @@
|
|||||||
"version": "24.13.2",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@ -2051,6 +2073,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -2382,6 +2405,7 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@ -2500,6 +2524,7 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@ -2762,7 +2787,7 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@ -2850,6 +2875,7 @@
|
|||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@ -2882,6 +2908,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2902,6 +2929,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2922,6 +2950,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2942,6 +2971,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2962,6 +2992,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2982,6 +3013,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -3005,6 +3037,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"musl"
|
"musl"
|
||||||
],
|
],
|
||||||
@ -3028,6 +3061,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"glibc"
|
"glibc"
|
||||||
],
|
],
|
||||||
@ -3051,6 +3085,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"libc": [
|
"libc": [
|
||||||
"musl"
|
"musl"
|
||||||
],
|
],
|
||||||
@ -3074,6 +3109,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3094,6 +3130,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3208,6 +3245,7 @@
|
|||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -3313,12 +3351,14 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -3331,6 +3371,7 @@
|
|||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -3552,6 +3593,7 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.133.0",
|
"@oxc-project/types": "=0.133.0",
|
||||||
@ -3630,6 +3672,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -3666,6 +3709,7 @@
|
|||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -3695,6 +3739,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@ -3753,7 +3798,7 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
@ -3832,6 +3877,7 @@
|
|||||||
"version": "8.0.16",
|
"version": "8.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import Media from './pages/Media';
|
|||||||
import CMS from './pages/CMS';
|
import CMS from './pages/CMS';
|
||||||
import WholesaleApplications from './pages/WholesaleApplications';
|
import WholesaleApplications from './pages/WholesaleApplications';
|
||||||
import Videos from './pages/Videos';
|
import Videos from './pages/Videos';
|
||||||
import ContactSubmissions from './pages/ContactSubmissions';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -45,7 +44,6 @@ function App() {
|
|||||||
<Route path="media" element={<Media />} />
|
<Route path="media" element={<Media />} />
|
||||||
<Route path="cms" element={<CMS />} />
|
<Route path="cms" element={<CMS />} />
|
||||||
<Route path="wholesale" element={<WholesaleApplications />} />
|
<Route path="wholesale" element={<WholesaleApplications />} />
|
||||||
<Route path="contact" element={<ContactSubmissions />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
|
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video } from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
const menuGroups = [
|
const menuGroups = [
|
||||||
@ -26,7 +26,6 @@ const menuGroups = [
|
|||||||
{ icon: Users, label: 'کاربران', path: '/users' },
|
{ icon: Users, label: 'کاربران', path: '/users' },
|
||||||
{ icon: Heart, label: 'حیوانات (Pets)', path: '/pets' },
|
{ icon: Heart, label: 'حیوانات (Pets)', path: '/pets' },
|
||||||
{ icon: Package, label: 'درخواستهای B2B', path: '/wholesale' },
|
{ icon: Package, label: 'درخواستهای B2B', path: '/wholesale' },
|
||||||
{ icon: PhoneCall, label: 'تماس با ما & اطلاعات', path: '/contact' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
toggleMenu: () => void;
|
toggleMenu: () => void;
|
||||||
@ -50,30 +49,6 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
setShowResults(false);
|
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 (
|
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">
|
<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}>
|
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
|
||||||
@ -124,80 +99,16 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Realtime Notifications (TASK-2.20) */}
|
<button className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-all relative">
|
||||||
<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" />
|
<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>
|
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex items-center gap-3 pl-2 border-l border-gray-200 ml-2">
|
||||||
{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">
|
<div className="text-left hidden md:block">
|
||||||
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
||||||
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
|
<p className="text-xs font-medium text-gray-500">ادمین ارشد</p>
|
||||||
</div>
|
</div>
|
||||||
<UserCircle className="w-10 h-10 text-purple-600" />
|
<UserCircle className="w-10 h-10 text-gray-300" />
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-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 api, { BASE_DOMAIN } from '../../services/api';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import Pagination from './Pagination';
|
|
||||||
import ConfirmModal from './ConfirmModal';
|
|
||||||
|
|
||||||
interface Media {
|
interface Media {
|
||||||
id: string;
|
id: string;
|
||||||
@ -25,44 +22,14 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
const [mediaList, setMediaList] = useState<Media[]>([]);
|
const [mediaList, setMediaList] = useState<Media[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const limit = 10;
|
|
||||||
const [pasteStatus, setPasteStatus] = useState<string | null>(null);
|
const [pasteStatus, setPasteStatus] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (isOpen) {
|
||||||
let isSubscribed = true;
|
fetchMedia();
|
||||||
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]);
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -112,9 +79,23 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
document.removeEventListener('paste', handlePaste);
|
document.removeEventListener('paste', handlePaste);
|
||||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [isOpen, multiple, onClose, onSelect, fetchMedia]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
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>) => {
|
||||||
if (!e.target.files || e.target.files.length === 0) return;
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -125,64 +106,25 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
await api.post('/admin/media/upload', formData, {
|
await api.post('/admin/media/upload', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
});
|
});
|
||||||
toast.success('تصویر با موفقیت آپلود شد');
|
|
||||||
fetchMedia();
|
fetchMedia();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed', error);
|
console.error('Upload failed', error);
|
||||||
toast.error('خطا در آپلود تصویر');
|
alert('خطا در آپلود تصویر');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||||
if (!deleteTargetId) return;
|
e.stopPropagation();
|
||||||
|
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
await api.delete(`/admin/media/${id}`);
|
||||||
toast.success('تصویر با موفقیت حذف شد');
|
setMediaList(mediaList.filter(m => m.id !== id));
|
||||||
setMediaList(mediaList.filter(m => m.id !== deleteTargetId));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -190,18 +132,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
|
|
||||||
return (
|
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="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm transition-opacity">
|
||||||
<div
|
<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">
|
||||||
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 */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
||||||
@ -237,7 +168,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileUpload}
|
onChange={handleUpload}
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@ -263,13 +194,9 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||||
<p className="font-medium text-gray-500">گالری خالی است. اولین عکس را آپلود کنید!</p>
|
<p className="font-medium text-gray-500">گالری خالی است. اولین عکس را آپلود کنید!</p>
|
||||||
</div>
|
</div>
|
||||||
) : (() => {
|
) : (
|
||||||
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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{paginatedList.map((media) => {
|
{mediaList.map((media) => {
|
||||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||||
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
const isSelected = selectedUrl && imgUrl === selectedUrl;
|
||||||
return (
|
return (
|
||||||
@ -289,7 +216,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
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"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@ -318,22 +245,10 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف تصویر"
|
|
||||||
message="آیا از حذف این تصویر مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface PaginationProps {
|
interface PaginationProps {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@ -8,126 +7,29 @@ interface PaginationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||||
const [jumpPage, setJumpPage] = useState('');
|
|
||||||
|
|
||||||
if (totalPages <= 1) return null;
|
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 (
|
return (
|
||||||
<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">
|
<div className="flex items-center justify-between p-4 border-t border-gray-200 bg-gray-50">
|
||||||
{/* Right: Direct Jump Input */}
|
<div className="flex items-center gap-2">
|
||||||
<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
|
<button
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
title="صفحه قبلی"
|
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"
|
||||||
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-4 h-4" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dynamic Page Numbers */}
|
<span className="text-sm font-bold text-gray-700 px-4">
|
||||||
{pages[0] > 1 && (
|
صفحه {currentPage} از {totalPages}
|
||||||
<>
|
</span>
|
||||||
<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
|
<button
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
title="صفحه بعدی"
|
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"
|
||||||
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-4 h-4" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,31 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Plus, Edit2, Trash2, FileText, Image as ImageIcon, CheckCircle2, XCircle } from 'lucide-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 api, { BASE_DOMAIN } from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import MediaSelector from '../components/ui/MediaSelector';
|
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() {
|
export default function Blogs() {
|
||||||
const [blogs, setBlogs] = useState<BlogPost[]>([]);
|
const [blogs, setBlogs] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -34,8 +15,7 @@ export default function Blogs() {
|
|||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||||||
const [editingBlog, setEditingBlog] = useState<BlogPost | null>(null);
|
const [editingBlog, setEditingBlog] = useState<any>(null);
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@ -48,7 +28,7 @@ export default function Blogs() {
|
|||||||
imageUrl: ''
|
imageUrl: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchBlogs = useCallback(async () => {
|
const fetchBlogs = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
|
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
|
||||||
@ -61,48 +41,42 @@ export default function Blogs() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchBlogs();
|
fetchBlogs();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchBlogs]);
|
}, [search, page]);
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (editingBlog) {
|
if (editingBlog) {
|
||||||
await api.put(`/admin/blogs/${editingBlog.id}`, formData);
|
await api.put(`/admin/blogs/${editingBlog.id}`, formData);
|
||||||
toast.success('مقاله با موفقیت ویرایش شد');
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/admin/blogs', formData);
|
await api.post('/admin/blogs', formData);
|
||||||
toast.success('مقاله جدید با موفقیت ذخیره شد');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchBlogs();
|
fetchBlogs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed', error);
|
console.error('Save failed', error);
|
||||||
toast.error('خطا در ذخیره مقاله');
|
alert('خطا در ذخیره مقاله');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف این مقاله مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/blogs/${deleteTargetId}`);
|
await api.delete(`/admin/blogs/${id}`);
|
||||||
toast.success('مقاله با موفقیت حذف شد');
|
|
||||||
fetchBlogs();
|
fetchBlogs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف مقاله');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModal = (blog: BlogPost | null = null) => {
|
const openModal = (blog: any = null) => {
|
||||||
if (blog) {
|
if (blog) {
|
||||||
setEditingBlog(blog);
|
setEditingBlog(blog);
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -197,7 +171,7 @@ export default function Blogs() {
|
|||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex items-center gap-2">
|
<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={() => 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={() => setDeleteTargetId(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={() => handleDelete(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -291,14 +265,6 @@ export default function Blogs() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
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() {
|
export default function CMS() {
|
||||||
const [banners, setBanners] = useState<HeroBanner[]>([]);
|
const [banners, setBanners] = useState<any[]>([]);
|
||||||
const [testimonials, setTestimonials] = useState<VetTestimonial[]>([]);
|
const [testimonials, setTestimonials] = useState<any[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<'banners' | 'testimonials'>('banners');
|
const [activeTab, setActiveTab] = useState<'banners' | 'testimonials'>('banners');
|
||||||
|
|
||||||
// Form States
|
// Form States
|
||||||
@ -32,8 +15,9 @@ export default function CMS() {
|
|||||||
const [clinicName, setClinicName] = useState('');
|
const [clinicName, setClinicName] = useState('');
|
||||||
const [quote, setQuote] = useState('');
|
const [quote, setQuote] = useState('');
|
||||||
|
|
||||||
const [deleteBannerId, setDeleteBannerId] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
const [deleteTestimonialId, setDeleteTestimonialId] = useState<string | null>(null);
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
@ -48,23 +32,6 @@ 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) => {
|
const handleAddBanner = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@ -78,9 +45,9 @@ export default function CMS() {
|
|||||||
setNewSubtitle('');
|
setNewSubtitle('');
|
||||||
setNewImageUrl('');
|
setNewImageUrl('');
|
||||||
fetchData();
|
fetchData();
|
||||||
toast.success('بنر با موفقیت ایجاد شد');
|
alert('بنر با موفقیت ایجاد شد');
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error('خطا در ایجاد بنر');
|
alert('خطا در ایجاد بنر');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,36 +65,22 @@ export default function CMS() {
|
|||||||
setClinicName('');
|
setClinicName('');
|
||||||
setQuote('');
|
setQuote('');
|
||||||
fetchData();
|
fetchData();
|
||||||
toast.success('نظر دامپزشک ثبت شد');
|
alert('نظر دامپزشک ثبت شد');
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error('خطا در ایجاد نظر دامپزشک');
|
alert('خطا در ایجاد نظر دامپزشک');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDeleteBanner = async () => {
|
const handleDeleteBanner = async (id: string) => {
|
||||||
if (!deleteBannerId) return;
|
if (!confirm('آیا از حذف بنر مطمئن هستید؟')) return;
|
||||||
try {
|
await api.delete(`/admin/cms/hero-banners/${id}`);
|
||||||
await api.delete(`/admin/cms/hero-banners/${deleteBannerId}`);
|
|
||||||
toast.success('بنر با موفقیت حذف شد');
|
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
|
||||||
toast.error('خطا در حذف بنر');
|
|
||||||
} finally {
|
|
||||||
setDeleteBannerId(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDeleteTestimonial = async () => {
|
const handleDeleteTestimonial = async (id: string) => {
|
||||||
if (!deleteTestimonialId) return;
|
if (!confirm('آیا از حذف نظر مطمئن هستید؟')) return;
|
||||||
try {
|
await api.delete(`/admin/cms/vet-testimonials/${id}`);
|
||||||
await api.delete(`/admin/cms/vet-testimonials/${deleteTestimonialId}`);
|
|
||||||
toast.success('نظر با موفقیت حذف شد');
|
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
|
||||||
toast.error('خطا در حذف نظر');
|
|
||||||
} finally {
|
|
||||||
setDeleteTestimonialId(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -171,13 +124,13 @@ export default function CMS() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{banners.map((b: HeroBanner) => (
|
{banners.map((b: any) => (
|
||||||
<div key={b.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
<div key={b.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-gray-900">{b.title}</h4>
|
<h4 className="font-bold text-gray-900">{b.title}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">{b.subtitle}</p>
|
<p className="text-xs text-gray-500 mt-1">{b.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<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 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -210,13 +163,13 @@ export default function CMS() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{testimonials.map((t: VetTestimonial) => (
|
{testimonials.map((t: any) => (
|
||||||
<div key={t.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
<div key={t.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-gray-900">{t.vetName} — <span className="text-blue-600">{t.clinicName}</span></h4>
|
<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>
|
<p className="text-xs text-gray-600 mt-2 font-medium">"{t.quote}"</p>
|
||||||
</div>
|
</div>
|
||||||
<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 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -224,22 +177,6 @@ export default function CMS() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteBannerId}
|
|
||||||
title="حذف بنر"
|
|
||||||
message="آیا از حذف این بنر مطمئن هستید؟"
|
|
||||||
onConfirm={confirmDeleteBanner}
|
|
||||||
onCancel={() => setDeleteBannerId(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTestimonialId}
|
|
||||||
title="حذف نظر دامپزشک"
|
|
||||||
message="آیا از حذف این نظر مطمئن هستید؟"
|
|
||||||
onConfirm={confirmDeleteTestimonial}
|
|
||||||
onCancel={() => setDeleteTestimonialId(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Plus, Edit2, Trash2, FolderTree, Image as ImageIcon, X } from 'lucide-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 api, { BASE_DOMAIN } from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import MediaSelector from '../components/ui/MediaSelector';
|
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() {
|
export default function Categories() {
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -29,8 +15,7 @@ export default function Categories() {
|
|||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<any>(null);
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@ -42,7 +27,7 @@ export default function Categories() {
|
|||||||
imageUrl: ''
|
imageUrl: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchCategories = useCallback(async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await api.get('/admin/categories', { params: { page, limit, search } });
|
const res = await api.get('/admin/categories', { params: { page, limit, search } });
|
||||||
@ -55,48 +40,42 @@ export default function Categories() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchCategories]);
|
}, [search, page]);
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (editingCategory) {
|
if (editingCategory) {
|
||||||
await api.put(`/admin/categories/${editingCategory.id}`, formData);
|
await api.put(`/admin/categories/${editingCategory.id}`, formData);
|
||||||
toast.success('دستهبندی با موفقیت ویرایش شد');
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/admin/categories', formData);
|
await api.post('/admin/categories', formData);
|
||||||
toast.success('دستهبندی جدید با موفقیت ایجاد شد');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed', error);
|
console.error('Save failed', error);
|
||||||
toast.error('خطا در ذخیره دستهبندی');
|
alert('خطا در ذخیره دستهبندی');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/categories/${deleteTargetId}`);
|
await api.delete(`/admin/categories/${id}`);
|
||||||
toast.success('دستهبندی با موفقیت حذف شد');
|
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف دستهبندی');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModal = (category: Category | null = null) => {
|
const openModal = (category: any = null) => {
|
||||||
if (category) {
|
if (category) {
|
||||||
setEditingCategory(category);
|
setEditingCategory(category);
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -200,7 +179,7 @@ export default function Categories() {
|
|||||||
<button onClick={() => openModal(cat)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
<button onClick={() => openModal(cat)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeleteTargetId(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
<button onClick={() => handleDelete(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -339,14 +318,6 @@ export default function Categories() {
|
|||||||
onClose={() => setIsMediaSelectorOpen(false)}
|
onClose={() => setIsMediaSelectorOpen(false)}
|
||||||
onSelect={(url) => setFormData({...formData, imageUrl: url})}
|
onSelect={(url) => setFormData({...formData, imageUrl: url})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف دستهبندی"
|
|
||||||
message="آیا از حذف این دستهبندی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,292 +0,0 @@
|
|||||||
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,46 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield, HelpCircle } from 'lucide-react';
|
import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
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() {
|
export default function Coupons() {
|
||||||
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
const [coupons, setCoupons] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -48,14 +13,13 @@ export default function Coupons() {
|
|||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
|
const [editingCoupon, setEditingCoupon] = useState<any>(null);
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const [formData, setFormData] = useState({
|
||||||
const [formData, setFormData] = useState<CouponFormData>({
|
|
||||||
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true,
|
code: '', type: 'percent', value: 0, minCartValue: '', maxCartValue: '', maxUses: '', expiresAt: '', isActive: true,
|
||||||
targets: []
|
targets: [] as any[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
|
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
|
||||||
@ -68,14 +32,14 @@ export default function Coupons() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => fetchData(), 500);
|
const timer = setTimeout(() => fetchData(), 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchData]);
|
}, [search, page]);
|
||||||
|
|
||||||
const openModal = (coupon: Coupon | null = null) => {
|
const openModal = (coupon: any = null) => {
|
||||||
if (coupon) {
|
if (coupon) {
|
||||||
setEditingCoupon(coupon);
|
setEditingCoupon(coupon);
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -113,42 +77,30 @@ export default function Coupons() {
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingCoupon) {
|
if (editingCoupon) await api.put(`/admin/coupons/${editingCoupon.id}`, payload);
|
||||||
await api.put(`/admin/coupons/${editingCoupon.id}`, payload);
|
else await api.post('/admin/coupons', payload);
|
||||||
toast.success('کد تخفیف با موفقیت ویرایش شد');
|
|
||||||
} else {
|
|
||||||
await api.post('/admin/coupons', payload);
|
|
||||||
toast.success('کد تخفیف با موفقیت ایجاد شد');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save error', err);
|
console.error('Save error', err);
|
||||||
toast.error('خطا در ذخیره کد تخفیف');
|
alert('خطا در ذخیره کد تخفیف');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف این کد تخفیف مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/coupons/${deleteTargetId}`);
|
await api.delete(`/admin/coupons/${id}`);
|
||||||
toast.success('کد تخفیف با موفقیت حذف شد');
|
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
} catch (err) {}
|
||||||
toast.error('خطا در حذف کد تخفیف');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (id: string, currentStatus: boolean) => {
|
const handleToggle = async (id: string, currentStatus: boolean) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/coupons/${id}/toggle`, { isActive: !currentStatus });
|
await api.put(`/admin/coupons/${id}/toggle`, { isActive: !currentStatus });
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
} catch (err) {}
|
||||||
toast.error('خطا در تغییر وضعیت کد تخفیف');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -217,7 +169,7 @@ export default function Coupons() {
|
|||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex items-center gap-2">
|
<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={() => 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={() => setDeleteTargetId(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={() => handleDelete(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -233,29 +185,13 @@ export default function Coupons() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف کد تخفیف"
|
|
||||||
message="آیا از حذف این کد تخفیف مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={() => setIsModalOpen(false)} isEditing={!!editingCoupon} />}
|
{isModalOpen && <CouponModal formData={formData} setFormData={setFormData} onSave={handleSave} onClose={() => setIsModalOpen(false)} isEditing={!!editingCoupon} />}
|
||||||
</div>
|
</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
|
// Sub-component for Modal to keep code clean
|
||||||
function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: CouponModalProps) {
|
function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any) {
|
||||||
const addTarget = () => {
|
const addTarget = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
@ -269,9 +205,9 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
|
|||||||
setFormData({ ...formData, targets: nt });
|
setFormData({ ...formData, targets: nt });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTarget = (index: number, key: keyof CouponTarget, val: string | number) => {
|
const updateTarget = (index: number, key: string, val: any) => {
|
||||||
const nt = [...formData.targets];
|
const nt = [...formData.targets];
|
||||||
nt[index] = { ...nt[index], [key]: val };
|
nt[index][key] = val;
|
||||||
setFormData({ ...formData, targets: nt });
|
setFormData({ ...formData, targets: nt });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -301,92 +237,36 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
|
|||||||
<h4 className="font-bold text-gray-800 border-b border-gray-100 pb-2">تنظیمات پایه</h4>
|
<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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700">کد تخفیف *</label>
|
||||||
<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" />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700">نوع *</label>
|
||||||
<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">
|
<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="percent">درصدی (٪)</option>
|
||||||
<option value="fixed">مبلغ ثابت (تومان)</option>
|
<option value="fixed">مبلغ ثابت (تومان)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700">مقدار *</label>
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
<label className="text-xs font-bold text-gray-700">حداقل خرید</label>
|
||||||
<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" />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
<label className="text-xs font-bold text-gray-700">حداکثر تخفیف</label>
|
||||||
<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" />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
<label className="text-xs font-bold text-gray-700">دفعات مجاز</label>
|
||||||
<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" />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
|
<label className="text-xs font-bold text-gray-700">انقضا</label>
|
||||||
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -407,7 +287,7 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{formData.targets.map((t: CouponTarget, i: number) => (
|
{formData.targets.map((t: any, 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 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">
|
<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>
|
<label className="text-xs font-bold text-gray-500 flex items-center gap-1">{getTypeIcon(t.targetType)} نوع هدف</label>
|
||||||
|
|||||||
@ -4,21 +4,8 @@ import { DollarSign, ShoppingCart, Users, Activity } from 'lucide-react';
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||||
import api from '../services/api';
|
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() {
|
export default function Dashboard() {
|
||||||
const [data, setData] = useState<DashboardData>({
|
const [data, setData] = useState({
|
||||||
revenue: 0,
|
revenue: 0,
|
||||||
newOrders: 0,
|
newOrders: 0,
|
||||||
users: 0,
|
users: 0,
|
||||||
@ -49,12 +36,12 @@ export default function Dashboard() {
|
|||||||
{ name: 'جمعه', بازدید: 349, فروش: 4300 },
|
{ name: 'جمعه', بازدید: 349, فروش: 4300 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const pieData = data.categoriesDistribution || [
|
const pieData = [
|
||||||
{ name: 'مکمل سگ', value: 8 },
|
{ name: 'سگ', value: 400 },
|
||||||
{ name: 'مکمل گربه', value: 6 },
|
{ name: 'گربه', value: 300 },
|
||||||
{ name: 'سگ و گربه', value: 12 },
|
{ name: 'پرنده', value: 300 },
|
||||||
];
|
];
|
||||||
const COLORS = ['#8b5cf6', '#3b82f6', '#10b981'];
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ title: 'درآمد کل', value: `${data.revenue.toLocaleString()} تومان`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-100', link: '/reports' },
|
{ title: 'درآمد کل', value: `${data.revenue.toLocaleString()} تومان`, icon: DollarSign, color: 'text-green-600', bg: 'bg-green-100', link: '/reports' },
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Lock, Mail, ChevronLeft } from 'lucide-react';
|
import { Lock, Mail, ChevronLeft } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@ -18,11 +17,10 @@ export default function Login() {
|
|||||||
const response = await api.post('/auth/admin-login', { email, password });
|
const response = await api.post('/auth/admin-login', { email, password });
|
||||||
if (response.data?.success) {
|
if (response.data?.success) {
|
||||||
localStorage.setItem('adminToken', response.data.data.accessToken);
|
localStorage.setItem('adminToken', response.data.data.accessToken);
|
||||||
toast.success('ورود با موفقیت انجام شد');
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error('ایمیل یا رمز عبور اشتباه است');
|
alert('ایمیل یا رمز عبور اشتباه است');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-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 api, { BASE_DOMAIN } from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
|
||||||
|
|
||||||
interface Media {
|
interface Media {
|
||||||
id: string;
|
id: string;
|
||||||
@ -12,10 +9,6 @@ interface Media {
|
|||||||
filename: string;
|
filename: string;
|
||||||
mimetype: string;
|
mimetype: string;
|
||||||
size: number;
|
size: number;
|
||||||
altText?: string;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
caption?: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,54 +17,14 @@ export default function MediaManager() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const limit = 10;
|
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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(() => {
|
useEffect(() => {
|
||||||
let isSubscribed = true;
|
fetchMedia();
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
@ -90,26 +43,36 @@ export default function MediaManager() {
|
|||||||
};
|
};
|
||||||
document.addEventListener('paste', handlePaste);
|
document.addEventListener('paste', handlePaste);
|
||||||
return () => document.removeEventListener('paste', handlePaste);
|
return () => document.removeEventListener('paste', handlePaste);
|
||||||
}, [uploadFile]);
|
}, []);
|
||||||
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const fetchMedia = async () => {
|
||||||
const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
|
|
||||||
const [seoFormData, setSeoFormData] = useState({
|
|
||||||
altText: '',
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
caption: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSaveSeo = async () => {
|
|
||||||
if (!editingSeoMedia) return;
|
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/media/${editingSeoMedia.id}`, seoFormData);
|
setIsLoading(true);
|
||||||
toast.success('تنظیمات سئوی تصویر با موفقیت ذخیره شد');
|
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 uploadFile = 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' }
|
||||||
|
});
|
||||||
fetchMedia();
|
fetchMedia();
|
||||||
setEditingSeoMedia(null);
|
} catch (error) {
|
||||||
} catch {
|
console.error('Upload failed', error);
|
||||||
toast.error('خطا در ذخیره تنظیمات سئوی تصویر');
|
alert('خطا در آپلود فایل');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,17 +91,13 @@ export default function MediaManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
await api.delete(`/admin/media/${id}`);
|
||||||
toast.success('تصویر با موفقیت حذف شد');
|
setMediaList(prev => prev.filter(m => m.id !== id));
|
||||||
setMediaList(prev => prev.filter(m => m.id !== deleteTargetId));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف تصویر');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -147,7 +106,8 @@ export default function MediaManager() {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(fullUrl);
|
await navigator.clipboard.writeText(fullUrl);
|
||||||
setCopiedId(media.id);
|
setCopiedId(media.id);
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||||
|
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.value = fullUrl;
|
textarea.value = fullUrl;
|
||||||
@ -156,7 +116,8 @@ export default function MediaManager() {
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
setCopiedId(media.id);
|
setCopiedId(media.id);
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||||
|
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -238,13 +199,9 @@ export default function MediaManager() {
|
|||||||
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
|
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (() => {
|
) : (
|
||||||
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-6">
|
||||||
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
{filtered.map(media => {
|
||||||
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}`;
|
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -268,23 +225,7 @@ export default function MediaManager() {
|
|||||||
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => { e.stopPropagation(); handleDelete(media.id); }}
|
||||||
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"
|
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="حذف"
|
title="حذف"
|
||||||
>
|
>
|
||||||
@ -309,10 +250,7 @@ export default function MediaManager() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
@ -346,90 +284,6 @@ export default function MediaManager() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Eye,
|
Eye,
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
Package,
|
Package,
|
||||||
|
CreditCard,
|
||||||
Save,
|
Save,
|
||||||
Download
|
Download
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -22,43 +23,7 @@ import Skeleton from '../components/ui/Skeleton';
|
|||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
|
|
||||||
export interface OrderItem {
|
const statusStyles: Record<string, { label: string; color: string; icon: any }> = {
|
||||||
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 },
|
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 },
|
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 },
|
shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Truck },
|
||||||
@ -73,7 +38,7 @@ const toPersianDigits = (n: string | number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Orders() {
|
export default function Orders() {
|
||||||
const [orders, setOrders] = useState<Order[]>([]);
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
@ -84,12 +49,12 @@ export default function Orders() {
|
|||||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
|
||||||
const [modalTrackingCode, setModalTrackingCode] = useState('');
|
const [modalTrackingCode, setModalTrackingCode] = useState('');
|
||||||
const [modalStatus, setModalStatus] = useState('');
|
const [modalStatus, setModalStatus] = useState('');
|
||||||
const [isSavingTracking, setIsSavingTracking] = useState(false);
|
const [isSavingTracking, setIsSavingTracking] = useState(false);
|
||||||
|
|
||||||
const fetchOrders = useCallback(async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@ -104,7 +69,7 @@ export default function Orders() {
|
|||||||
|
|
||||||
// Update selectedOrder if open
|
// Update selectedOrder if open
|
||||||
if (selectedOrder) {
|
if (selectedOrder) {
|
||||||
const updated = response.data.data.find((o: Order) => o.id === selectedOrder.id);
|
const updated = response.data.data.find((o: any) => o.id === selectedOrder.id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
setSelectedOrder(updated);
|
setSelectedOrder(updated);
|
||||||
}
|
}
|
||||||
@ -115,7 +80,7 @@ export default function Orders() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, status, selectedOrder]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delayDebounceFn = setTimeout(() => {
|
const delayDebounceFn = setTimeout(() => {
|
||||||
@ -123,7 +88,7 @@ export default function Orders() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(delayDebounceFn);
|
return () => clearTimeout(delayDebounceFn);
|
||||||
}, [fetchOrders]);
|
}, [page, search, status]);
|
||||||
|
|
||||||
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
|
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
|
||||||
try {
|
try {
|
||||||
@ -140,7 +105,7 @@ export default function Orders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openOrderModal = (order: Order) => {
|
const openOrderModal = (order: any) => {
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
setModalTrackingCode(order.trackingNumber || '');
|
setModalTrackingCode(order.trackingNumber || '');
|
||||||
setModalStatus(order.status || 'processing');
|
setModalStatus(order.status || 'processing');
|
||||||
@ -162,18 +127,18 @@ export default function Orders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrintInvoice = (orderToPrint: Order) => {
|
const handlePrintInvoice = (orderToPrint: any) => {
|
||||||
if (!orderToPrint) return;
|
if (!orderToPrint) return;
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
if (!printWindow) return;
|
if (!printWindow) return;
|
||||||
|
|
||||||
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date || Date.now());
|
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date);
|
||||||
const formattedDate = toPersianDigits(orderDate.toLocaleDateString("fa-IR"));
|
const formattedDate = toPersianDigits(orderDate.toLocaleDateString("fa-IR"));
|
||||||
const formattedTime = toPersianDigits(orderDate.toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }));
|
const formattedTime = toPersianDigits(orderDate.toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }));
|
||||||
const trackingCode = orderToPrint.trackingNumber || orderToPrint.id.substring(0, 8);
|
const trackingCode = orderToPrint.trackingNumber || orderToPrint.id.substring(0, 8);
|
||||||
|
|
||||||
const items = orderToPrint.orderItems || orderToPrint.items || [];
|
const items = orderToPrint.orderItems || orderToPrint.items || [];
|
||||||
const itemsSubtotal = items.reduce((sum: number, item: OrderItem) => {
|
const itemsSubtotal = items.reduce((sum: number, item: any) => {
|
||||||
const price = Number(item.product?.priceValue || item.priceValue || 0);
|
const price = Number(item.product?.priceValue || item.priceValue || 0);
|
||||||
return sum + price * (item.quantity || 1);
|
return sum + price * (item.quantity || 1);
|
||||||
}, 0);
|
}, 0);
|
||||||
@ -185,7 +150,7 @@ export default function Orders() {
|
|||||||
const customerName = orderToPrint.user ? `${orderToPrint.user.firstName || ''} ${orderToPrint.user.lastName || ''}`.trim() : 'مشتری مهمان';
|
const customerName = orderToPrint.user ? `${orderToPrint.user.firstName || ''} ${orderToPrint.user.lastName || ''}`.trim() : 'مشتری مهمان';
|
||||||
const paymentMethodText = orderToPrint.paymentMethod === 'wallet' ? 'پرداخت از کیف پول الکترونیک' : 'پرداخت آنلاین از درگاه شتاب';
|
const paymentMethodText = orderToPrint.paymentMethod === 'wallet' ? 'پرداخت از کیف پول الکترونیک' : 'پرداخت آنلاین از درگاه شتاب';
|
||||||
|
|
||||||
const itemsHtml = items.map((item: OrderItem) => {
|
const itemsHtml = items.map((item: any) => {
|
||||||
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
||||||
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
||||||
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
||||||
@ -580,7 +545,7 @@ export default function Orders() {
|
|||||||
اقلام خریده شده ({toPersianDigits((selectedOrder.orderItems || selectedOrder.items || []).length)})
|
اقلام خریده شده ({toPersianDigits((selectedOrder.orderItems || selectedOrder.items || []).length)})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(selectedOrder.orderItems || selectedOrder.items || []).map((item: OrderItem, idx: number) => {
|
{(selectedOrder.orderItems || selectedOrder.items || []).map((item: any, idx: number) => {
|
||||||
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
|
||||||
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
|
||||||
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
|
||||||
|
|||||||
@ -1,44 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Trash2, Heart, Image as ImageIcon } from 'lucide-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 api, { BASE_DOMAIN } from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
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() {
|
export default function Pets() {
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const fetchPets = async () => {
|
||||||
|
|
||||||
const fetchPets = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await api.get('/admin/pets', { params: { page, limit, search } });
|
const res = await api.get('/admin/pets', { params: { page, limit, search } });
|
||||||
@ -51,26 +25,22 @@ export default function Pets() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchPets();
|
fetchPets();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchPets]);
|
}, [search, page]);
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/pets/${deleteTargetId}`);
|
await api.delete(`/admin/pets/${id}`);
|
||||||
toast.success('پروفایل حیوان خانگی با موفقیت حذف شد');
|
|
||||||
fetchPets();
|
fetchPets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف پروفایل');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -142,7 +112,7 @@ export default function Pets() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setDeleteTargetId(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
<button 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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -157,14 +127,6 @@ export default function Pets() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف حیوان خانگی"
|
|
||||||
message="آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +1,21 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Filter, Box, Plus, Edit2, Trash2, Image as ImageIcon, X, HelpCircle } from 'lucide-react';
|
import { Search, Filter, Box, Plus, Edit2, Trash2, Image as ImageIcon, X, HelpCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api, { BASE_DOMAIN } from '../services/api';
|
import api, { BASE_DOMAIN } from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import MediaSelector from '../components/ui/MediaSelector';
|
import MediaSelector from '../components/ui/MediaSelector';
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
id: string;
|
|
||||||
nameFa: string;
|
|
||||||
nameEn?: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Product {
|
|
||||||
name?: string;
|
|
||||||
category?: Category | string;
|
|
||||||
id: string;
|
|
||||||
artNo: string;
|
|
||||||
nameFa: string;
|
|
||||||
nameEn?: string;
|
|
||||||
scientificTagline?: string;
|
|
||||||
description?: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
categoryId?: string;
|
|
||||||
priceValue?: number;
|
|
||||||
wholesalePrice?: number;
|
|
||||||
buyPrice?: number;
|
|
||||||
priceDisplay?: string;
|
|
||||||
unit?: string;
|
|
||||||
packageSize?: number;
|
|
||||||
dosageLogic?: string;
|
|
||||||
suitableFor?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
metaTitle?: string;
|
|
||||||
metaDescription?: string;
|
|
||||||
keywords?: string;
|
|
||||||
canonicalUrl?: string;
|
|
||||||
slug?: string;
|
|
||||||
images?: string[];
|
|
||||||
podcastUrl?: string;
|
|
||||||
videoUrl?: string;
|
|
||||||
pdfUrl?: string;
|
|
||||||
symptoms?: Array<{ symptom: string } | string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Products() {
|
export default function Products() {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<any[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// URL and String Helper Functions
|
// URL and String Helper Functions
|
||||||
const getProductImageUrl = (url: string) => {
|
const getProductImageUrl = (url: string) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
if (url.startsWith('http')) {
|
if (url.startsWith('http')) {
|
||||||
const path = url.replace(/^https?:\/\/[^/]+/, '');
|
const path = url.replace(/^https?:\/\/[^\/]+/, '');
|
||||||
return `${BASE_DOMAIN}${path}`;
|
return `${BASE_DOMAIN}${path}`;
|
||||||
}
|
}
|
||||||
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
|
return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`;
|
||||||
@ -76,7 +36,7 @@ export default function Products() {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^a-z0-9-]/g, '')
|
.replace(/[^a-z0-9\-]/g, '')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
};
|
};
|
||||||
@ -88,22 +48,12 @@ export default function Products() {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
||||||
const [mediaImageError, setMediaImageError] = useState(false);
|
const [mediaImageError, setMediaImageError] = useState(false);
|
||||||
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
|
||||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
|
||||||
const [availableSymptoms, setAvailableSymptoms] = useState<string[]>([
|
|
||||||
"درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع تولهسگ", "پاهای پرانتزی", "ضعف تاندون",
|
|
||||||
"اسهال", "یبوست", "اسهال مزمن", "بیاشتهایی", "ضعف بعد از بیماری", "ریزش مو", "خشکی پوست", "خارش", "جرم دندان"
|
|
||||||
]);
|
|
||||||
const [symptomInput, setSymptomInput] = useState('');
|
|
||||||
const [showSymptomSuggestions, setShowSymptomSuggestions] = useState(false);
|
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||||||
const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'podcastUrl' | 'videoUrl' | 'pdfUrl'>('imageUrl');
|
const [editingProduct, setEditingProduct] = useState<any>(null);
|
||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@ -114,11 +64,7 @@ export default function Products() {
|
|||||||
description: '',
|
description: '',
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
buyPrice: '' as number | string,
|
|
||||||
priceValue: 0,
|
priceValue: 0,
|
||||||
wholesalePrice: '' as number | string,
|
|
||||||
priceValueMarginPercent: '' as number | string,
|
|
||||||
wholesaleMarginPercent: '' as number | string,
|
|
||||||
priceDisplay: '',
|
priceDisplay: '',
|
||||||
unit: '',
|
unit: '',
|
||||||
packageSize: 0,
|
packageSize: 0,
|
||||||
@ -130,15 +76,12 @@ export default function Products() {
|
|||||||
keywords: '',
|
keywords: '',
|
||||||
canonicalUrl: '',
|
canonicalUrl: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
images: [] as string[],
|
|
||||||
podcastUrl: '',
|
|
||||||
videoUrl: '',
|
|
||||||
pdfUrl: '',
|
|
||||||
symptoms: [] as string[]
|
symptoms: [] as string[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
// Fetch products first
|
// Fetch products first
|
||||||
const prodRes = await api.get('/admin/products', { params: { page, limit, search, categoryId: categoryFilter } });
|
const prodRes = await api.get('/admin/products', { params: { page, limit, search, categoryId: categoryFilter } });
|
||||||
if (prodRes.data?.data) {
|
if (prodRes.data?.data) {
|
||||||
@ -157,32 +100,25 @@ export default function Products() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Could not fetch categories', err);
|
console.warn('Could not fetch categories', err);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error('خطا در دریافت لیست محصولات');
|
toast.error('خطا در دریافت لیست محصولات: ' + (err.response?.data?.message || err.message));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, categoryFilter]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchData]);
|
}, [search, categoryFilter, page]);
|
||||||
|
|
||||||
const openModal = (product: Product | null = null) => {
|
const openModal = (product: any = null) => {
|
||||||
setMediaImageError(false);
|
setMediaImageError(false);
|
||||||
if (product) {
|
if (product) {
|
||||||
setEditingProduct(product);
|
setEditingProduct(product);
|
||||||
const bPrice = product.buyPrice ? Number(product.buyPrice) : '';
|
|
||||||
const pPrice = Number(product.priceValue || 0);
|
|
||||||
const wPrice = product.wholesalePrice ? Number(product.wholesalePrice) : '';
|
|
||||||
|
|
||||||
const pMargin = bPrice && pPrice ? Math.round(((pPrice - Number(bPrice)) / Number(bPrice)) * 100) : '';
|
|
||||||
const wMargin = bPrice && wPrice ? Math.round(((wPrice - Number(bPrice)) / Number(bPrice)) * 100) : '';
|
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
artNo: product.artNo,
|
artNo: product.artNo,
|
||||||
nameFa: product.nameFa || '',
|
nameFa: product.nameFa || '',
|
||||||
@ -191,11 +127,8 @@ export default function Products() {
|
|||||||
description: product.description || '',
|
description: product.description || '',
|
||||||
shortDescription: product.shortDescription || '',
|
shortDescription: product.shortDescription || '',
|
||||||
categoryId: product.categoryId || '',
|
categoryId: product.categoryId || '',
|
||||||
buyPrice: bPrice,
|
priceValue: Number(product.priceValue),
|
||||||
priceValue: pPrice,
|
wholesalePrice: product.wholesalePrice ? Number(product.wholesalePrice) : '',
|
||||||
wholesalePrice: wPrice,
|
|
||||||
priceValueMarginPercent: pMargin,
|
|
||||||
wholesaleMarginPercent: wMargin,
|
|
||||||
priceDisplay: product.priceDisplay || '',
|
priceDisplay: product.priceDisplay || '',
|
||||||
unit: product.unit || '',
|
unit: product.unit || '',
|
||||||
packageSize: Number(product.packageSize),
|
packageSize: Number(product.packageSize),
|
||||||
@ -207,20 +140,14 @@ export default function Products() {
|
|||||||
keywords: product.keywords || '',
|
keywords: product.keywords || '',
|
||||||
canonicalUrl: product.canonicalUrl || '',
|
canonicalUrl: product.canonicalUrl || '',
|
||||||
slug: product.slug || '',
|
slug: product.slug || '',
|
||||||
images: Array.isArray(product.images) ? product.images : [],
|
symptoms: product.symptoms ? product.symptoms.map((s: any) => s.symptom) : []
|
||||||
podcastUrl: product.podcastUrl || '',
|
|
||||||
videoUrl: product.videoUrl || '',
|
|
||||||
pdfUrl: product.pdfUrl || '',
|
|
||||||
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : []
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
artNo: '', nameFa: '', nameEn: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '',
|
artNo: '', nameFa: '', nameEn: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '',
|
||||||
buyPrice: '', priceValue: 0, wholesalePrice: '', priceValueMarginPercent: '', wholesaleMarginPercent: '',
|
priceValue: 0, wholesalePrice: '', priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه',
|
||||||
priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه',
|
|
||||||
imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '',
|
imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '',
|
||||||
images: [], podcastUrl: '', videoUrl: '', pdfUrl: '',
|
|
||||||
symptoms: []
|
symptoms: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -228,44 +155,12 @@ export default function Products() {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageFileUpload = async (file: File) => {
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
toast.error('لطفاً یک فایل تصویری انتخاب کنید');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsUploadingImage(true);
|
|
||||||
const fileData = new FormData();
|
|
||||||
fileData.append('file', file);
|
|
||||||
|
|
||||||
const res = await api.post('/admin/media/upload', fileData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.data?.url) {
|
|
||||||
setFormData(prev => ({ ...prev, imageUrl: res.data.url }));
|
|
||||||
setMediaImageError(false);
|
|
||||||
toast.success('تصویر با موفقیت آپلود و جایگزین شد');
|
|
||||||
} else {
|
|
||||||
toast.error('خطا در دریافت آدرس فایل آپلود شده');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Image upload failed', err);
|
|
||||||
toast.error('خطا در آپلود فایل تصویر');
|
|
||||||
} finally {
|
|
||||||
setIsUploadingImage(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const derivedSlug = formData.slug.trim() || slugify(formData.nameEn);
|
const derivedSlug = formData.slug.trim() || slugify(formData.nameEn);
|
||||||
const finalPayload = {
|
const finalPayload = {
|
||||||
...formData,
|
...formData,
|
||||||
buyPrice: formData.buyPrice ? Number(formData.buyPrice) : 0,
|
|
||||||
wholesalePrice: formData.wholesalePrice ? Number(formData.wholesalePrice) : null,
|
|
||||||
slug: derivedSlug,
|
slug: derivedSlug,
|
||||||
metaTitle: formData.metaTitle.trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کانینا`,
|
metaTitle: formData.metaTitle.trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کانینا`,
|
||||||
metaDescription: formData.metaDescription.trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کانینا آلمان با بالاترین کیفیت بالینی...',
|
metaDescription: formData.metaDescription.trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کانینا آلمان با بالاترین کیفیت بالینی...',
|
||||||
@ -274,32 +169,26 @@ export default function Products() {
|
|||||||
|
|
||||||
if (editingProduct) {
|
if (editingProduct) {
|
||||||
await api.put(`/admin/products/${editingProduct.id}`, finalPayload);
|
await api.put(`/admin/products/${editingProduct.id}`, finalPayload);
|
||||||
toast.success('محصول با موفقیت ویرایش شد');
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/admin/products', finalPayload);
|
await api.post('/admin/products', finalPayload);
|
||||||
toast.success('محصول با موفقیت ایجاد شد');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setImageErrors({});
|
setImageErrors({});
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed', error);
|
console.error('Save failed', error);
|
||||||
toast.error('خطا در ذخیره محصول');
|
alert('خطا در ذخیره محصول');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف این محصول مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/products/${deleteTargetId}`);
|
await api.delete(`/admin/products/${id}`);
|
||||||
toast.success('محصول با موفقیت حذف شد');
|
|
||||||
setImageErrors({});
|
setImageErrors({});
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف محصول');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -341,7 +230,7 @@ export default function Products() {
|
|||||||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none"
|
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none"
|
||||||
>
|
>
|
||||||
<option value="">همه دستهبندیها</option>
|
<option value="">همه دستهبندیها</option>
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{typeof c === 'string' ? c : (c.nameFa || c.name)}</option>)}
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -396,11 +285,7 @@ export default function Products() {
|
|||||||
{product.nameEn && <span className="text-xs text-gray-400 font-mono" dir="ltr">{product.nameEn}</span>}
|
{product.nameEn && <span className="text-xs text-gray-400 font-mono" dir="ltr">{product.nameEn}</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-gray-600 text-sm">
|
<td className="py-4 px-6 text-gray-600 text-sm">{product.category?.name || 'بدون دسته'}</td>
|
||||||
{typeof product.category === 'object' && product.category !== null
|
|
||||||
? (product.category.nameFa || product.category.name || 'بدون دسته')
|
|
||||||
: (typeof product.category === 'string' ? product.category : 'بدون دسته')}
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-gray-900">{Number(product.priceValue).toLocaleString()} تومان</span>
|
<span className="font-bold text-gray-900">{Number(product.priceValue).toLocaleString()} تومان</span>
|
||||||
@ -412,7 +297,7 @@ export default function Products() {
|
|||||||
<button onClick={() => openModal(product)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
<button onClick={() => openModal(product)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeleteTargetId(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
<button onClick={() => handleDelete(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -538,7 +423,7 @@ export default function Products() {
|
|||||||
</label>
|
</label>
|
||||||
<select required value={formData.categoryId} onChange={(e) => setFormData({...formData, categoryId: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
<select required value={formData.categoryId} onChange={(e) => setFormData({...formData, categoryId: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
||||||
<option value="">انتخاب کنید</option>
|
<option value="">انتخاب کنید</option>
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{typeof c === 'string' ? c : (c.nameFa || c.name)}</option>)}
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -619,292 +504,59 @@ export default function Products() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<div className="relative flex-1 min-w-[200px]">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجو یا تایپ علامت جدید..."
|
placeholder="تایپ و اینتر کنید..."
|
||||||
value={symptomInput}
|
className="flex-1 bg-transparent border-none outline-none text-sm min-w-[150px] text-right font-vazir"
|
||||||
onChange={(e) => {
|
|
||||||
setSymptomInput(e.target.value);
|
|
||||||
setShowSymptomSuggestions(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setShowSymptomSuggestions(true)}
|
|
||||||
className="w-full bg-transparent border-none outline-none text-sm text-right font-vazir"
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const val = symptomInput.trim();
|
const input = e.currentTarget;
|
||||||
if (val && !formData.symptoms.includes(val)) {
|
const value = input.value.trim();
|
||||||
|
if (value && !formData.symptoms.includes(value)) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
symptoms: [...(formData.symptoms || []), val]
|
symptoms: [...(formData.symptoms || []), value]
|
||||||
});
|
});
|
||||||
if (!availableSymptoms.includes(val)) {
|
input.value = '';
|
||||||
setAvailableSymptoms([...availableSymptoms, val]);
|
|
||||||
}
|
|
||||||
setSymptomInput('');
|
|
||||||
setShowSymptomSuggestions(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Autocomplete Suggestions Popup */}
|
|
||||||
{showSymptomSuggestions && availableSymptoms.filter(s => s.includes(symptomInput) && !formData.symptoms.includes(s)).length > 0 && (
|
|
||||||
<div className="absolute top-full right-0 z-50 mt-1 w-64 max-h-48 overflow-y-auto bg-white border border-gray-200 rounded-xl shadow-xl p-1">
|
|
||||||
{availableSymptoms
|
|
||||||
.filter(s => s.includes(symptomInput) && !formData.symptoms.includes(s))
|
|
||||||
.map((s) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={s}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault(); // Prevent blur before click executes
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
symptoms: [...(formData.symptoms || []), s]
|
|
||||||
});
|
|
||||||
setSymptomInput('');
|
|
||||||
setShowSymptomSuggestions(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-right px-3 py-1.5 hover:bg-purple-50 hover:text-purple-700 text-xs font-bold rounded-lg transition-colors flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span>{s}</span>
|
|
||||||
<span className="text-[10px] text-gray-400">+ افزودن</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<p className="text-[11px] text-gray-400 font-medium">پس از نوشتن هر علامت درمانی (مثلاً «درد مفاصل»)، کلید Enter را فشار دهید.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-gray-400 font-medium">از منوی پیشنهادی انتخاب کنید یا علامت جدید بنویسید و کلید Enter را بزنید.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dosage Calculator Field in General Tab */}
|
|
||||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span>منطق و دوز دقیق مصرفی بالینی (Dosage Calculator Fields)</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 bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-64 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
|
||||||
دستور مصرف دقیق کالا (مانند: ۲ قرص به ازای هر ۱۰ کیلوگرم وزن بدن در روز). این متن در کاتالوگ و کارت مشخصات کالا به صورت برجسته نمایش داده میشود.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-purple-600 font-bold">دستور مصرف بالینی / JSON</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={formData.dosageLogic}
|
|
||||||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
|
||||||
placeholder='مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن یا فرمت JSON: {"baseDosage": 1, "perKg": 10}'
|
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-vazir"
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-gray-400">
|
|
||||||
این متن در بخش مشخصات علمی کالا و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Tab */}
|
{/* Pricing Tab */}
|
||||||
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
|
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
|
||||||
<div className="bg-amber-50/60 border border-amber-200 p-4 rounded-xl text-xs text-amber-800 flex items-start gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<HelpCircle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-bold mb-1">امنیت و حریم خصوصی قیمت خرید (سود و زیان)</p>
|
|
||||||
<p className="leading-relaxed text-[11px]">
|
|
||||||
قیمت خرید و درصدهای سود به هیچ عنوان در پاسخهای API عمومی فرانتاند سایت منتشر نمیشوند و فقط جهت محاسبات حسابداری و گزارشگیری سود/زیان در پنل ادمین استفاده میشوند.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{/* Buy Price */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-amber-900 flex items-center gap-1.5">
|
<label className="text-sm font-bold text-gray-700">قیمت (تومان) *</label>
|
||||||
<span>قیمت خرید ما (تومان)</span>
|
<input required type="number" min="0" value={formData.priceValue} onChange={(e) => setFormData({...formData, priceValue: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
||||||
<div className="group relative inline-block">
|
|
||||||
<HelpCircle className="w-3.5 h-3.5 text-amber-500 cursor-help" />
|
|
||||||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
|
||||||
مبلغ تمام شده خرید کالا برای مبنای محاسبات سود و محاسبه خودکار قیمت فروش با درصد.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-purple-700 flex items-center gap-1">
|
||||||
|
<span>قیمت اختصاصی B2B (تومان)</span>
|
||||||
|
<span className="text-[10px] font-normal text-gray-400">(اختیاری)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input type="number" min="0" placeholder="در صورت عدم ورود، درصد کلی اعمال میشود" value={formData.wholesalePrice} onChange={(e) => setFormData({...formData, wholesalePrice: e.target.value ? Number(e.target.value) : ''})} className="w-full px-4 py-3 rounded-xl border border-purple-200 focus:border-purple-600 outline-none font-bold bg-purple-50/20" dir="ltr" />
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
placeholder="مثال: 500000"
|
|
||||||
value={formData.buyPrice}
|
|
||||||
onChange={(e) => {
|
|
||||||
const bPrice = e.target.value ? Number(e.target.value) : '';
|
|
||||||
let pPrice = formData.priceValue;
|
|
||||||
let wPrice = formData.wholesalePrice;
|
|
||||||
let pMargin = formData.priceValueMarginPercent;
|
|
||||||
let wMargin = formData.wholesaleMarginPercent;
|
|
||||||
|
|
||||||
if (bPrice && pMargin !== '') {
|
|
||||||
pPrice = Math.round(Number(bPrice) * (1 + Number(pMargin) / 100));
|
|
||||||
} else if (bPrice && pPrice) {
|
|
||||||
pMargin = Math.round(((pPrice - Number(bPrice)) / Number(bPrice)) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bPrice && wMargin !== '') {
|
|
||||||
wPrice = Math.round(Number(bPrice) * (1 + Number(wMargin) / 100));
|
|
||||||
} else if (bPrice && wPrice) {
|
|
||||||
wMargin = Math.round(((Number(wPrice) - Number(bPrice)) / Number(bPrice)) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
buyPrice: bPrice,
|
|
||||||
priceValue: pPrice,
|
|
||||||
wholesalePrice: wPrice,
|
|
||||||
priceValueMarginPercent: pMargin,
|
|
||||||
wholesaleMarginPercent: wMargin,
|
|
||||||
priceDisplay: `${pPrice.toLocaleString('fa-IR')} تومان`,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-3 rounded-xl border border-amber-300 focus:border-amber-500 outline-none bg-amber-50/20 font-bold"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Retail Price & Margin */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<label className="text-sm font-bold text-gray-700">نمایش قیمت (متنی) *</label>
|
||||||
<label className="text-sm font-bold text-gray-700">قیمت فروش تکفروشی (تومان) *</label>
|
<input required type="text" placeholder="مثال: ۸۵۰,۰۰۰ تومان" value={formData.priceDisplay} onChange={(e) => setFormData({...formData, priceDisplay: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||||
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ تکفروشی</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={formData.priceValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value);
|
|
||||||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
|
||||||
const pMargin = bPrice && val ? Math.round(((val - bPrice) / bPrice) * 100) : '';
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
priceValue: val,
|
|
||||||
priceValueMarginPercent: pMargin,
|
|
||||||
priceDisplay: `${val.toLocaleString('fa-IR')} تومان`,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="col-span-3 px-3 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<div className="col-span-2 relative">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="٪ سود"
|
|
||||||
value={formData.priceValueMarginPercent}
|
|
||||||
onChange={(e) => {
|
|
||||||
const margin = e.target.value !== '' ? Number(e.target.value) : '';
|
|
||||||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
|
||||||
let pPrice = formData.priceValue;
|
|
||||||
if (bPrice && margin !== '') {
|
|
||||||
pPrice = Math.round(bPrice * (1 + Number(margin) / 100));
|
|
||||||
}
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
priceValueMarginPercent: margin,
|
|
||||||
priceValue: pPrice,
|
|
||||||
priceDisplay: `${pPrice.toLocaleString('fa-IR')} تومان`,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-3 rounded-xl border border-purple-200 focus:border-purple-500 text-center font-bold text-xs bg-purple-50/30 outline-none"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none">٪</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wholesale B2B Price & Margin */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<label className="text-sm font-bold text-purple-700">قیمت اختصاصی B2B (تومان)</label>
|
|
||||||
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ B2B</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
placeholder="قیمت B2B"
|
|
||||||
value={formData.wholesalePrice}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value ? Number(e.target.value) : '';
|
|
||||||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
|
||||||
const wMargin = bPrice && val ? Math.round(((Number(val) - bPrice) / bPrice) * 100) : '';
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
wholesalePrice: val,
|
|
||||||
wholesaleMarginPercent: wMargin,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="col-span-3 px-3 py-3 rounded-xl border border-purple-200 focus:border-purple-600 outline-none font-bold bg-purple-50/20"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<div className="col-span-2 relative">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="٪ سود"
|
|
||||||
value={formData.wholesaleMarginPercent}
|
|
||||||
onChange={(e) => {
|
|
||||||
const margin = e.target.value !== '' ? Number(e.target.value) : '';
|
|
||||||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
|
||||||
let wPrice = formData.wholesalePrice;
|
|
||||||
if (bPrice && margin !== '') {
|
|
||||||
wPrice = Math.round(bPrice * (1 + Number(margin) / 100));
|
|
||||||
}
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
wholesaleMarginPercent: margin,
|
|
||||||
wholesalePrice: wPrice,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-3 rounded-xl border border-purple-200 focus:border-purple-500 text-center font-bold text-xs bg-purple-50/30 outline-none"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none">٪</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">موجودی *</label>
|
<label className="text-sm font-bold text-gray-700">موجودی *</label>
|
||||||
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({...formData, packageSize: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({...formData, packageSize: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-bold text-gray-700">واحد سنجش</label>
|
<label className="text-sm font-bold text-gray-700">واحد (Unit) *</label>
|
||||||
<input type="text" placeholder="مثال: عدد / بسته" value={formData.unit} onChange={(e) => setFormData({ ...formData, unit: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
<input required type="text" placeholder="عدد، گرم، میلیلیتر" value={formData.unit} onChange={(e) => setFormData({...formData, unit: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 col-span-1 md:col-span-2 pt-2">
|
|
||||||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
|
||||||
<span>منطق و دوز دقیق مصرفی (Dosage Calculator Fields)</span>
|
|
||||||
<span className="text-xs text-purple-600 font-bold">فرمت JSON یا راهنمای متنی</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={formData.dosageLogic}
|
|
||||||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
|
||||||
placeholder='{"baseDosage": 1, "perKg": 10, "unit": "قرص", "maxPerDay": 3}'
|
|
||||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[11px] text-gray-400">
|
|
||||||
این مقادیر توسط محاسبهگر هوشمند دوز مکمل در فرانتاند خوانده میشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* SEO Tab */}
|
{/* SEO Tab */}
|
||||||
<div className={activeTab === 'seo' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'seo' ? 'block' : 'hidden'}>
|
||||||
@ -1119,83 +771,6 @@ export default function Products() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multimedia Attachments (TASK-2.3 & TASK-2.4) */}
|
|
||||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
|
||||||
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-bold text-gray-700 block">لینک فصلی پادکست (Podcast URL)</label>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.podcastUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, podcastUrl: e.target.value })}
|
|
||||||
placeholder="https://... MP3 or Soundcloud"
|
|
||||||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMediaTargetField('podcastUrl');
|
|
||||||
setIsMediaSelectorOpen(true);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
انتخاب
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-bold text-gray-700 block">لینک یا کدفریم ویدیو (Video URL)</label>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.videoUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
|
|
||||||
placeholder="https://... MP4 or Aparat/YouTube iframe"
|
|
||||||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMediaTargetField('videoUrl');
|
|
||||||
setIsMediaSelectorOpen(true);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
انتخاب
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-bold text-gray-700 block">فایل کاتالوگ / PDF علمی (PDF URL)</label>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.pdfUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, pdfUrl: e.target.value })}
|
|
||||||
placeholder="https://... PDF catalog"
|
|
||||||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMediaTargetField('pdfUrl');
|
|
||||||
setIsMediaSelectorOpen(true);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
انتخاب
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
@ -1218,18 +793,10 @@ export default function Products() {
|
|||||||
isOpen={isMediaSelectorOpen}
|
isOpen={isMediaSelectorOpen}
|
||||||
onClose={() => setIsMediaSelectorOpen(false)}
|
onClose={() => setIsMediaSelectorOpen(false)}
|
||||||
onSelect={(url) => {
|
onSelect={(url) => {
|
||||||
setFormData({ ...formData, [mediaTargetField]: url });
|
setFormData({...formData, imageUrl: url});
|
||||||
if (mediaTargetField === 'imageUrl') setMediaImageError(false);
|
setMediaImageError(false);
|
||||||
}}
|
}}
|
||||||
selectedUrl={formData[mediaTargetField]?.startsWith('http') ? formData[mediaTargetField] : formData[mediaTargetField] ? `${BASE_DOMAIN}${formData[mediaTargetField]}` : undefined}
|
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `${BASE_DOMAIN}${formData.imageUrl}` : undefined}
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف محصول"
|
|
||||||
message="آیا از حذف این محصول مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,13 +9,37 @@ import {
|
|||||||
|
|
||||||
const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B'];
|
const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B'];
|
||||||
|
|
||||||
interface CustomTooltipProps {
|
export default function Reports() {
|
||||||
active?: boolean;
|
const [reportData, setReportData] = useState<any>(null);
|
||||||
payload?: Array<{ value: number | string }>;
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
label?: string;
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const reportRes = await api.get('/admin/reports');
|
||||||
|
if (reportRes.data?.success) {
|
||||||
|
setReportData(reportRes.data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex justify-center items-center h-96"><Spinner size="lg" className="text-purple-600" /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
if (!reportData) return <div className="text-center py-12 text-gray-500 font-bold">اطلاعاتی یافت نشد</div>;
|
||||||
|
|
||||||
|
const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData;
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
|
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
|
||||||
@ -27,63 +51,6 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
return null;
|
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<ReportData | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isSubscribed = true;
|
|
||||||
api.get('/admin/reports').then(reportRes => {
|
|
||||||
if (!isSubscribed) return;
|
|
||||||
if (reportRes.data?.success) {
|
|
||||||
setReportData(reportRes.data.data);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
}).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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!reportData) return <div className="text-center py-12 text-gray-500 font-bold">اطلاعاتی یافت نشد</div>;
|
|
||||||
|
|
||||||
const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||||||
@ -166,11 +133,11 @@ export default function Reports() {
|
|||||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
<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>
|
<h3 className="text-lg font-bold text-gray-900 mb-6">سهم فروش دستهبندیها</h3>
|
||||||
<div className="h-64 w-full flex justify-center">
|
<div className="h-64 w-full flex justify-center">
|
||||||
{categoryDistribution && categoryDistribution.length > 0 ? (
|
{categoryDistribution?.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={categoryDistribution} cx="50%" cy="50%" innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
|
<Pie data={categoryDistribution} cx="50%" cy="50%" innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
|
||||||
{categoryDistribution.map((_, index: number) => (
|
{categoryDistribution.map((_: any, index: number) => (
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@ -210,8 +177,8 @@ export default function Reports() {
|
|||||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
<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>
|
<h3 className="text-lg font-bold text-gray-900 mb-6">پرکاربردترین کدهای تخفیف</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{topCoupons && topCoupons.length > 0 ? (
|
{topCoupons?.length > 0 ? (
|
||||||
topCoupons.map((coupon: TopCouponItem, idx: number) => (
|
topCoupons.map((coupon: any, idx: number) => (
|
||||||
<div key={idx} className="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100">
|
<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="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">
|
<div className="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 font-bold flex items-center justify-center text-sm">
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Settings as SettingsIcon, Save, Truck, ShieldAlert, Percent, AlertCircle, CreditCard, Share2 } from 'lucide-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 api from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
|
|
||||||
@ -10,7 +9,6 @@ export default function Settings() {
|
|||||||
MIN_ORDER_AMOUNT: '',
|
MIN_ORDER_AMOUNT: '',
|
||||||
B2B_DISCOUNT_PERCENT: '',
|
B2B_DISCOUNT_PERCENT: '',
|
||||||
MAINTENANCE_MODE: 'false',
|
MAINTENANCE_MODE: 'false',
|
||||||
CATALOG_ONLY_MODE: 'false',
|
|
||||||
CHARITY_ROUND_STEP: '10000',
|
CHARITY_ROUND_STEP: '10000',
|
||||||
PAY_GATEWAY_CARD_ENABLE: 'true',
|
PAY_GATEWAY_CARD_ENABLE: 'true',
|
||||||
PAY_GATEWAY_WALLET_ENABLE: 'true',
|
PAY_GATEWAY_WALLET_ENABLE: 'true',
|
||||||
@ -22,25 +20,20 @@ export default function Settings() {
|
|||||||
SOCIAL_INSTAGRAM: 'https://instagram.com/canina_iran',
|
SOCIAL_INSTAGRAM: 'https://instagram.com/canina_iran',
|
||||||
SOCIAL_TELEGRAM: 'https://t.me/canina_iran',
|
SOCIAL_TELEGRAM: 'https://t.me/canina_iran',
|
||||||
CONTACT_ADDRESS: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
CONTACT_ADDRESS: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
||||||
BRAND_LOGO_URL: '/logo.png',
|
|
||||||
ENAMAD_CODE: '',
|
|
||||||
THEME_PRIMARY_COLOR: '#7c3aed',
|
|
||||||
BRAND_TYPOGRAPHY: 'vazirmatn',
|
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchSettings = async () => {
|
||||||
let isSubscribed = true;
|
try {
|
||||||
api.get('/admin/settings').then(response => {
|
setIsLoading(true);
|
||||||
if (!isSubscribed) return;
|
const response = await api.get('/admin/settings');
|
||||||
if (response.data?.success) {
|
if (response.data?.success) {
|
||||||
setSettings({
|
setSettings({
|
||||||
SHIPPING_FEE: response.data.data.SHIPPING_FEE || '0',
|
SHIPPING_FEE: response.data.data.SHIPPING_FEE || '0',
|
||||||
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || '0',
|
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || '0',
|
||||||
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || '0',
|
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || '0',
|
||||||
MAINTENANCE_MODE: response.data.data.MAINTENANCE_MODE || 'false',
|
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',
|
CHARITY_ROUND_STEP: response.data.data.CHARITY_ROUND_STEP || '10000',
|
||||||
PAY_GATEWAY_CARD_ENABLE: response.data.data.PAY_GATEWAY_CARD_ENABLE || 'true',
|
PAY_GATEWAY_CARD_ENABLE: response.data.data.PAY_GATEWAY_CARD_ENABLE || 'true',
|
||||||
PAY_GATEWAY_WALLET_ENABLE: response.data.data.PAY_GATEWAY_WALLET_ENABLE || 'true',
|
PAY_GATEWAY_WALLET_ENABLE: response.data.data.PAY_GATEWAY_WALLET_ENABLE || 'true',
|
||||||
@ -52,20 +45,17 @@ export default function Settings() {
|
|||||||
SOCIAL_INSTAGRAM: response.data.data.SOCIAL_INSTAGRAM || 'https://instagram.com/canina_iran',
|
SOCIAL_INSTAGRAM: response.data.data.SOCIAL_INSTAGRAM || 'https://instagram.com/canina_iran',
|
||||||
SOCIAL_TELEGRAM: response.data.data.SOCIAL_TELEGRAM || 'https://t.me/canina_iran',
|
SOCIAL_TELEGRAM: response.data.data.SOCIAL_TELEGRAM || 'https://t.me/canina_iran',
|
||||||
CONTACT_ADDRESS: response.data.data.CONTACT_ADDRESS || 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
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);
|
console.error('Failed to fetch settings', err);
|
||||||
}).finally(() => {
|
} finally {
|
||||||
if (isSubscribed) setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
}
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
@ -73,10 +63,9 @@ export default function Settings() {
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await api.put('/admin/settings', settings);
|
await api.put('/admin/settings', settings);
|
||||||
toast.success('تنظیمات با موفقیت بروزرسانی شد.');
|
alert('تنظیمات با موفقیت بروزرسانی شد.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update settings', err);
|
console.error('Failed to update settings', err);
|
||||||
toast.error('خطا در بروزرسانی تنظیمات');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@ -320,86 +309,19 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand Identity & Theme Settings (TASK-2.15) */}
|
{/* System Settings */}
|
||||||
<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">
|
<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">
|
<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" />
|
<ShieldAlert className="w-5 h-5 text-orange-500" />
|
||||||
وضعیت سیستم و کنترل حالت کاتالوگ (TASK-2.14)
|
وضعیت سیستم
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<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 justify-between p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertCircle className="w-6 h-6 text-orange-500" />
|
<AlertCircle className="w-6 h-6 text-orange-500" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-gray-900 text-sm">حالت تعمیرات (Maintenance Mode)</h4>
|
<h4 className="font-bold text-gray-900">حالت تعمیرات (Maintenance Mode)</h4>
|
||||||
<p className="text-xs text-gray-600 mt-0.5">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و صفحه اختصاصی «در حال بروزرسانی» نمایش داده میشود.</p>
|
<p className="text-sm text-gray-600 mt-1">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و پیام «در حال بروزرسانی» نمایش داده میشود.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
@ -412,26 +334,6 @@ export default function Settings() {
|
|||||||
<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>
|
<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>
|
</label>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex justify-end">
|
<div className="lg:col-span-2 flex justify-end">
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Languages, Save, Search, Loader2, Layout, Sliders, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
|
import { Languages, Save, Search, Loader2, Layout, Sliders, Image, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
|
|
||||||
@ -14,9 +13,6 @@ const PAGE_TABS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS: Record<string, string[]> = {
|
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",
|
"shipping_notice", "brand_name_fa", "brand_subtitle",
|
||||||
"nav_solutions", "nav_products", "nav_wiki", "nav_blog", "nav_pet_profiles",
|
"nav_solutions", "nav_products", "nav_wiki", "nav_blog", "nav_pet_profiles",
|
||||||
@ -97,19 +93,20 @@ export default function UITexts() {
|
|||||||
const currentKeyForUpload = useRef<string | null>(null);
|
const currentKeyForUpload = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isSubscribed = true;
|
fetchTexts();
|
||||||
api.get('/settings/ui-texts').then(res => {
|
|
||||||
if (isSubscribed) setTexts(res.data || {});
|
|
||||||
}).catch(e => {
|
|
||||||
console.error('Failed to load UI texts:', e);
|
|
||||||
}).finally(() => {
|
|
||||||
if (isSubscribed) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchTexts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/settings/ui-texts');
|
||||||
|
setTexts(res.data || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load UI texts:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (key: string) => {
|
const handleSave = async (key: string) => {
|
||||||
const value = edits[key] ?? texts[key] ?? '';
|
const value = edits[key] ?? texts[key] ?? '';
|
||||||
setSaving(key);
|
setSaving(key);
|
||||||
@ -117,11 +114,10 @@ export default function UITexts() {
|
|||||||
await api.put(`/settings/ui-texts/${key}`, { value });
|
await api.put(`/settings/ui-texts/${key}`, { value });
|
||||||
setTexts(prev => ({ ...prev, [key]: value }));
|
setTexts(prev => ({ ...prev, [key]: value }));
|
||||||
setSavedKey(key);
|
setSavedKey(key);
|
||||||
toast.success('ذخیرهسازی با موفقیت انجام شد');
|
|
||||||
setTimeout(() => setSavedKey(null), 2000);
|
setTimeout(() => setSavedKey(null), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save UI text:', e);
|
console.error('Failed to save UI text:', e);
|
||||||
toast.error('خطا در ذخیرهسازی');
|
alert('خطا در ذخیرهسازی');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
@ -140,11 +136,10 @@ export default function UITexts() {
|
|||||||
await api.put(`/settings/ui-texts/${key}`, { value: fileUrl });
|
await api.put(`/settings/ui-texts/${key}`, { value: fileUrl });
|
||||||
setTexts(prev => ({ ...prev, [key]: fileUrl }));
|
setTexts(prev => ({ ...prev, [key]: fileUrl }));
|
||||||
setSavedKey(key);
|
setSavedKey(key);
|
||||||
toast.success('تصویر با موفقیت آپلود شد');
|
|
||||||
setTimeout(() => setSavedKey(null), 2000);
|
setTimeout(() => setSavedKey(null), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to upload image:', e);
|
console.error('Failed to upload image:', e);
|
||||||
toast.error('خطا در آپلود تصویر');
|
alert('خطا در آپلود تصویر');
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingKey(null);
|
setUploadingKey(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X } from 'lucide-react';
|
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X } from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import Skeleton from '../components/ui/Skeleton';
|
import Skeleton from '../components/ui/Skeleton';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
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() {
|
export default function Users() {
|
||||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
@ -31,7 +20,7 @@ export default function Users() {
|
|||||||
const [editRole, setEditRole] = useState<string>('');
|
const [editRole, setEditRole] = useState<string>('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@ -49,7 +38,7 @@ export default function Users() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, role]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const delayDebounceFn = setTimeout(() => {
|
const delayDebounceFn = setTimeout(() => {
|
||||||
@ -57,9 +46,9 @@ export default function Users() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(delayDebounceFn);
|
return () => clearTimeout(delayDebounceFn);
|
||||||
}, [fetchUsers]);
|
}, [page, search, role]);
|
||||||
|
|
||||||
const handleEditClick = (user: UserRecord) => {
|
const handleEditClick = (user: any) => {
|
||||||
setEditingId(user.id);
|
setEditingId(user.id);
|
||||||
setEditRole(user.role || 'User_PetOwner');
|
setEditRole(user.role || 'User_PetOwner');
|
||||||
};
|
};
|
||||||
@ -158,7 +147,8 @@ export default function Users() {
|
|||||||
<option value="ADMIN">ادمین</option>
|
<option value="ADMIN">ادمین</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
<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?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||||
}`}>
|
}`}>
|
||||||
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, X, Upload, Film } from 'lucide-react';
|
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, CheckCircle, X, Upload, Image as ImageIcon, Film, Code } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,7 +21,6 @@ export default function Videos() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
|
||||||
const [isUploadingThumbnail, setIsUploadingThumbnail] = useState(false);
|
const [isUploadingThumbnail, setIsUploadingThumbnail] = useState(false);
|
||||||
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
|
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
|
||||||
|
|
||||||
@ -40,20 +37,22 @@ export default function Videos() {
|
|||||||
isFeatured: false,
|
isFeatured: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchVideos = async () => {
|
||||||
let isSubscribed = true;
|
setIsLoading(true);
|
||||||
api.get('/videos', {
|
try {
|
||||||
|
const res = await api.get('/videos', {
|
||||||
params: { search: searchTerm, limit: 100 },
|
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 () => {
|
setVideos(res.data.data || res.data || []);
|
||||||
isSubscribed = false;
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch videos:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVideos();
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
const handleOpenModal = (video?: Video) => {
|
const handleOpenModal = (video?: Video) => {
|
||||||
@ -83,24 +82,16 @@ export default function Videos() {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement> | File, type: 'thumbnail' | 'video') => {
|
const handleFileUpload = async (file: File, type: 'thumbnail' | 'video') => {
|
||||||
let file: File | undefined;
|
const data = new FormData();
|
||||||
if (e instanceof File) {
|
data.append('file', 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 === 'thumbnail') setIsUploadingThumbnail(true);
|
||||||
else setIsUploadingVideo(true);
|
if (type === 'video') setIsUploadingVideo(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/admin/media/upload', uploadData, {
|
const res = await api.post('/cms/upload', data, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
const fileUrl = res.data.url || res.data.fileUrl;
|
const fileUrl = res.data.url || res.data.fileUrl;
|
||||||
if (type === 'thumbnail') {
|
if (type === 'thumbnail') {
|
||||||
@ -108,10 +99,9 @@ export default function Videos() {
|
|||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, videoUrl: fileUrl }));
|
setFormData(prev => ({ ...prev, videoUrl: fileUrl }));
|
||||||
}
|
}
|
||||||
toast.success('فایل با موفقیت آپلود شد');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err);
|
console.error('Upload failed:', err);
|
||||||
toast.error('خطا در آپلود فایل');
|
alert('خطا در آپلود فایل');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingThumbnail(false);
|
setIsUploadingThumbnail(false);
|
||||||
setIsUploadingVideo(false);
|
setIsUploadingVideo(false);
|
||||||
@ -123,30 +113,25 @@ export default function Videos() {
|
|||||||
try {
|
try {
|
||||||
if (editingVideo) {
|
if (editingVideo) {
|
||||||
await api.put(`/videos/${editingVideo.id}`, formData);
|
await api.put(`/videos/${editingVideo.id}`, formData);
|
||||||
toast.success('ویدئو با موفقیت ویرایش شد');
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/videos', formData);
|
await api.post('/videos', formData);
|
||||||
toast.success('ویدئو جدید با موفقیت ذخیره شد');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchVideosList();
|
fetchVideos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save video:', err);
|
console.error('Failed to save video:', err);
|
||||||
toast.error('خطا در ذخیرهسازی ویدئو');
|
alert('خطا در ذخیرهسازی ویدئو');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!deleteTargetId) return;
|
if (!window.confirm('آیا از حذف این ویدئوی آموزشی اطمینان دارید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/videos/${deleteTargetId}`);
|
await api.delete(`/videos/${id}`);
|
||||||
toast.success('ویدئو با موفقیت حذف شد');
|
fetchVideos();
|
||||||
fetchVideosList();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete video:', err);
|
console.error('Failed to delete video:', err);
|
||||||
toast.error('خطا در حذف ویدئو');
|
alert('خطا در حذف ویدئو');
|
||||||
} finally {
|
|
||||||
setDeleteTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -248,7 +233,7 @@ export default function Videos() {
|
|||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTargetId(video.id)}
|
onClick={() => handleDelete(video.id)}
|
||||||
className="p-2 hover:bg-red-50 text-red-600 rounded-xl transition-colors"
|
className="p-2 hover:bg-red-50 text-red-600 rounded-xl transition-colors"
|
||||||
title="حذف"
|
title="حذف"
|
||||||
>
|
>
|
||||||
@ -427,19 +412,6 @@ export default function Videos() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetId}
|
|
||||||
title="حذف ویدئوی آموزشی"
|
|
||||||
message="آیا از حذف این ویدئوی آموزشی اطمینان دارید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetId(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchVideosList() {
|
|
||||||
throw new Error('Function not implemented.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,70 +1,45 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
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() {
|
export default function WholesaleApplications() {
|
||||||
const [requests, setRequests] = useState<WholesaleRequest[]>([]);
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [approveTargetId, setApproveTargetId] = useState<string | null>(null);
|
|
||||||
const [rejectTargetId, setRejectTargetId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchRequestsData = async () => {
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/wholesale/requests');
|
const res = await api.get('/wholesale/requests');
|
||||||
setRequests(res.data || []);
|
setRequests(res.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch wholesale requests:', err);
|
console.error('Failed to fetch wholesale requests:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleApprove = async (userId: string) => {
|
||||||
let isMounted = true;
|
if (!confirm('آیا از تایید حساب همکار و اعطای قیمتهای عمده-۳۰٪ مطمئن هستید؟')) return;
|
||||||
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 {
|
try {
|
||||||
await api.put(`/wholesale/approve/${approveTargetId}`);
|
await api.put(`/wholesale/approve/${userId}`);
|
||||||
toast.success('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.');
|
alert('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.');
|
||||||
fetchRequestsData();
|
fetchRequests();
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error('خطا در ارتقای نقش کاربر');
|
alert('خطا در ارتقای نقش کاربر');
|
||||||
} finally {
|
|
||||||
setApproveTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmReject = async () => {
|
const handleReject = async (userId: string) => {
|
||||||
if (!rejectTargetId) return;
|
if (!confirm('آیا از رد درخواست همکاری مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.put(`/wholesale/reject/${rejectTargetId}`);
|
await api.put(`/wholesale/reject/${userId}`);
|
||||||
toast.success('درخواست رد شد.');
|
alert('درخواست رد شد.');
|
||||||
fetchRequestsData();
|
fetchRequests();
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error('خطا در رد درخواست');
|
alert('خطا در رد درخواست');
|
||||||
} finally {
|
|
||||||
setRejectTargetId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,14 +67,15 @@ export default function WholesaleApplications() {
|
|||||||
<td colSpan={4} className="text-center py-8 text-gray-400 font-medium">هیچ درخواستی در انتظار بررسی یافت نشد.</td>
|
<td colSpan={4} className="text-center py-8 text-gray-400 font-medium">هیچ درخواستی در انتظار بررسی یافت نشد.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
requests.map((u: WholesaleRequest) => (
|
requests.map((u: any) => (
|
||||||
<tr key={u.id} className="hover:bg-gray-50/60 transition-colors">
|
<tr key={u.id} className="hover:bg-gray-50/60 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="font-bold text-gray-900">{u.fullName || u.email || 'کاربر بدون نام'}</div>
|
<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>
|
<div className="text-xs text-gray-400 dir-ltr text-right">{u.email || u.phoneNumber}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<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'}
|
{u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'}
|
||||||
</span>
|
</span>
|
||||||
@ -110,14 +86,14 @@ export default function WholesaleApplications() {
|
|||||||
<td className="px-6 py-4 text-center space-x-2 space-x-reverse">
|
<td className="px-6 py-4 text-center space-x-2 space-x-reverse">
|
||||||
{u.role !== 'User_Wholesale' && (
|
{u.role !== 'User_Wholesale' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setApproveTargetId(u.id)}
|
onClick={() => handleApprove(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"
|
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
|
تایید و اعطای لایسنس B2B
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setRejectTargetId(u.id)}
|
onClick={() => handleReject(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"
|
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"
|
||||||
>
|
>
|
||||||
رد درخواست
|
رد درخواست
|
||||||
@ -130,26 +106,6 @@ export default function WholesaleApplications() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
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() {
|
export default function Wiki() {
|
||||||
const [terms, setTerms] = useState<WikiTerm[]>([]);
|
const [terms, setTerms] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -32,21 +13,19 @@ export default function Wiki() {
|
|||||||
const limit = 10;
|
const limit = 10;
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingTerm, setEditingTerm] = useState<WikiTerm | null>(null);
|
const [editingTerm, setEditingTerm] = useState<any>(null);
|
||||||
const [deleteTargetKey, setDeleteTargetKey] = useState<string | null>(null);
|
|
||||||
const [productsList, setProductsList] = useState<ProductItem[]>([]);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
key: '',
|
key: '',
|
||||||
term: '',
|
term: '',
|
||||||
definition: '',
|
definition: '',
|
||||||
relatedProducts: [] as string[],
|
|
||||||
wikiId: 'general',
|
wikiId: 'general',
|
||||||
metaTitle: '',
|
metaTitle: '',
|
||||||
metaDescription: '',
|
metaDescription: '',
|
||||||
keywords: ''
|
keywords: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchTerms = useCallback(async () => {
|
const fetchTerms = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
|
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
|
||||||
@ -59,62 +38,49 @@ export default function Wiki() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchTerms();
|
fetchTerms();
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [fetchTerms]);
|
}, [search, page]);
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (editingTerm) {
|
if (editingTerm) {
|
||||||
await api.put(`/admin/wiki/${editingTerm.key}`, formData);
|
await api.put(`/admin/wiki/${editingTerm.key}`, formData);
|
||||||
toast.success('اصطلاح علمی با موفقیت ویرایش شد');
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/admin/wiki', formData);
|
await api.post('/admin/wiki', formData);
|
||||||
toast.success('اصطلاح علمی جدید با موفقیت ایجاد شد');
|
|
||||||
}
|
}
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchTerms();
|
fetchTerms();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save failed', error);
|
console.error('Save failed', error);
|
||||||
toast.error('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
alert('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const handleDelete = async (key: string) => {
|
||||||
if (!deleteTargetKey) return;
|
if (!window.confirm('آیا از حذف این مورد مطمئن هستید؟')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/admin/wiki/${deleteTargetKey}`);
|
await api.delete(`/admin/wiki/${key}`);
|
||||||
toast.success('اصطلاح علمی با موفقیت حذف شد');
|
|
||||||
fetchTerms();
|
fetchTerms();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed', error);
|
console.error('Delete failed', error);
|
||||||
toast.error('خطا در حذف اصطلاح علمی');
|
|
||||||
} finally {
|
|
||||||
setDeleteTargetKey(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const openModal = (term: any = null) => {
|
||||||
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) {
|
if (term) {
|
||||||
setEditingTerm(term);
|
setEditingTerm(term);
|
||||||
setFormData({
|
setFormData({
|
||||||
key: term.key,
|
key: term.key,
|
||||||
term: term.term,
|
term: term.term,
|
||||||
definition: term.definition,
|
definition: term.definition,
|
||||||
relatedProducts: Array.isArray(term.relatedProducts) ? term.relatedProducts : [],
|
wikiId: term.wikiId,
|
||||||
wikiId: term.wikiId || 'general',
|
|
||||||
metaTitle: term.metaTitle || '',
|
metaTitle: term.metaTitle || '',
|
||||||
metaDescription: term.metaDescription || '',
|
metaDescription: term.metaDescription || '',
|
||||||
keywords: term.keywords || ''
|
keywords: term.keywords || ''
|
||||||
@ -122,7 +88,7 @@ export default function Wiki() {
|
|||||||
} else {
|
} else {
|
||||||
setEditingTerm(null);
|
setEditingTerm(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
key: '', term: '', definition: '', wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -185,7 +151,7 @@ export default function Wiki() {
|
|||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex items-center gap-2">
|
<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={() => 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={() => setDeleteTargetKey(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={() => handleDelete(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -231,43 +197,6 @@ 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>
|
<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>
|
</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">
|
<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>
|
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -294,14 +223,6 @@ export default function Wiki() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={!!deleteTargetKey}
|
|
||||||
title="حذف اصطلاح علمی"
|
|
||||||
message="آیا از حذف این مورد مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
|
||||||
onConfirm={confirmDelete}
|
|
||||||
onCancel={() => setDeleteTargetKey(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export interface ApiErrorPayload {
|
|||||||
statusCode: number;
|
statusCode: number;
|
||||||
message: string;
|
message: string;
|
||||||
code: string;
|
code: string;
|
||||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,14 +9,11 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import CartDrawer from "../components/CartDrawer";
|
import CartDrawer from "../components/CartDrawer";
|
||||||
import LoginModal from "../components/LoginModal";
|
import LoginModal from "../components/LoginModal";
|
||||||
import AuthModal from "../components/AuthModal";
|
|
||||||
import B2BPortal from "../components/B2BPortal";
|
import B2BPortal from "../components/B2BPortal";
|
||||||
import { useUserStore } from "../lib/store/userStore";
|
import { useUserStore } from "../lib/store/userStore";
|
||||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
import { useUIStore } from "../lib/store/uiStore";
|
import { useUIStore } from "../lib/store/uiStore";
|
||||||
|
|
||||||
import MaintenancePage from "../components/MaintenancePage";
|
|
||||||
|
|
||||||
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||||
const {
|
const {
|
||||||
isCartOpen, isLoginModalOpen, isB2BPortalOpen, advisorData,
|
isCartOpen, isLoginModalOpen, isB2BPortalOpen, advisorData,
|
||||||
@ -26,9 +23,8 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { fetchProfile, role } = useUserStore();
|
const { fetchProfile } = useUserStore();
|
||||||
const fetchSettings = useSettingsStore(state => state.fetchSettings);
|
const fetchSettings = useSettingsStore(state => state.fetchSettings);
|
||||||
const getText = useSettingsStore(state => state.getText);
|
|
||||||
const isInitialized = useSettingsStore(state => state.isInitialized);
|
const isInitialized = useSettingsStore(state => state.isInitialized);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -41,13 +37,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
|||||||
}
|
}
|
||||||
}, [fetchProfile, fetchSettings]);
|
}, [fetchProfile, fetchSettings]);
|
||||||
|
|
||||||
// Check Maintenance Mode (Admin / Partner bypass)
|
// Remove full-page blocking screen to allow immediate render
|
||||||
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
|
// Derived currentView from pathname for Header
|
||||||
let currentView = "home";
|
let currentView = "home";
|
||||||
@ -113,13 +103,9 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
|||||||
onLogin={() => {
|
onLogin={() => {
|
||||||
setLoginModalOpen(false);
|
setLoginModalOpen(false);
|
||||||
}}
|
}}
|
||||||
petName={advisorData?.name as string | undefined}
|
petName={advisorData?.name}
|
||||||
isAdvisorContext={!!advisorData}
|
isAdvisorContext={!!advisorData}
|
||||||
/>
|
/>
|
||||||
<AuthModal
|
|
||||||
isOpen={useUserStore(state => state.isAuthModalOpen)}
|
|
||||||
onClose={() => useUserStore.getState().setAuthModalOpen(false)}
|
|
||||||
/>
|
|
||||||
<Toaster position="top-center" expand={true} richColors closeButton />
|
<Toaster position="top-center" expand={true} richColors closeButton />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import SafeImage from "../../components/SafeImage";
|
import SafeImage from "../../components/SafeImage";
|
||||||
import BackButton from "../../components/BackButton";
|
|
||||||
import { Download, Printer, ShieldCheck, Sparkles, CheckCircle2, ChevronLeft, Search, Filter } from "lucide-react";
|
import { Download, Printer, ShieldCheck, Sparkles, CheckCircle2, ChevronLeft, Search, Filter } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { productService } from "../../lib/services/productService";
|
import { productService } from "../../lib/services/productService";
|
||||||
import { Product } from "../../lib/data/products";
|
import { Product } from "../../lib/data/products";
|
||||||
import { toPersian } from "../../lib/utils";
|
import { toPersian } from "../../lib/utils";
|
||||||
|
|
||||||
|
|
||||||
export default function CatalogClient() {
|
export default function CatalogClient() {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -91,7 +89,6 @@ export default function CatalogClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<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
|
<button
|
||||||
onClick={handlePrint}
|
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"
|
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 type { Metadata } from "next";
|
||||||
import ContactFormClient from "@/components/ContactFormClient";
|
import { Mail, Phone, MapPin, Clock, Send } from "lucide-react";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "تماس با ما - نمایندگی رسمی مکملهای کانینا آلمان در ایران",
|
title: "تماس با ما - نمایندگی رسمی مکملهای کانینا آلمان در ایران",
|
||||||
@ -35,10 +35,83 @@ export default function ContactPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContactFormClient />
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -102,9 +102,9 @@
|
|||||||
font-family: "Vazirmatn" !important;
|
font-family: "Vazirmatn" !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body, #root {
|
||||||
font-family: "Vazirmatn" !important;
|
font-family: "Vazirmatn" !important;
|
||||||
@apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw] overflow-x-hidden;
|
@apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw];
|
||||||
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
|
font-feature-settings: "ss01", "ss02", "cv01", "cv11";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,22 +175,3 @@
|
|||||||
.sleek-hscroll::-webkit-scrollbar-thumb:hover {
|
.sleek-hscroll::-webkit-scrollbar-thumb:hover {
|
||||||
background: #003da6;
|
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) {
|
if (!product) {
|
||||||
return {
|
return {
|
||||||
title: 'خرید مکمل درمانی پت | کانینا ایران',
|
title: 'محصول یافت نشد',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import {
|
|||||||
Loader2
|
Loader2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCartStore } from "../lib/store/cartStore";
|
import { useCartStore } from "../lib/store/cartStore";
|
||||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
@ -163,12 +162,8 @@ const ArchiveProductCard: React.FC<{ product: Product }> = ({ product }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between pt-4 border-t border-medical-gray-50 gap-2">
|
<div className="mt-auto flex items-center justify-between pt-4 border-t border-medical-gray-50 gap-2">
|
||||||
{!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="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);
|
const cartItem = useCartStore.getState().items.find(i => i.product.id === product.id);
|
||||||
if (cartItem && cartItem.quantity > 0) {
|
if (cartItem && cartItem.quantity > 0) {
|
||||||
return (
|
return (
|
||||||
@ -242,8 +237,6 @@ export default function ArchivePage({
|
|||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false);
|
const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false);
|
||||||
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
|
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<string>("createdAt");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [meta, setMeta] = useState({ total: 0, lastPage: 1 });
|
const [meta, setMeta] = useState({ total: 0, lastPage: 1 });
|
||||||
|
|
||||||
@ -343,8 +336,6 @@ export default function ArchivePage({
|
|||||||
category: selectedCategory,
|
category: selectedCategory,
|
||||||
petType: selectedPet,
|
petType: selectedPet,
|
||||||
query: searchQuery,
|
query: searchQuery,
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
limit: 999,
|
limit: 999,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -373,7 +364,7 @@ export default function ArchivePage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter, sortBy, sortOrder]);
|
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter]);
|
||||||
|
|
||||||
const toggleSymptom = (s: string) => {
|
const toggleSymptom = (s: string) => {
|
||||||
setActiveSymptoms(prev => prev.includes(s) ? prev.filter(item => item !== s) : [...prev, s]);
|
setActiveSymptoms(prev => prev.includes(s) ? prev.filter(item => item !== s) : [...prev, s]);
|
||||||
@ -419,17 +410,9 @@ export default function ArchivePage({
|
|||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<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 */}
|
{/* Sidebar Filters - Collapsible on Mobile */}
|
||||||
<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'}`}>
|
<aside className={`lg:w-72 flex-shrink-0 space-y-6 ${isMobileFilterOpen ? 'block' : '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="bg-white rounded-[2rem] p-6 border border-medical-gray-200 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="w-5 h-5 text-canina-blue" />
|
<Filter className="w-5 h-5 text-canina-blue" />
|
||||||
@ -437,7 +420,7 @@ export default function ArchivePage({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileFilterOpen(false)}
|
onClick={() => setIsMobileFilterOpen(false)}
|
||||||
className="lg:hidden text-xs text-medical-gray-400 font-bold p-1 hover:text-red-500"
|
className="lg:hidden text-xs text-medical-gray-400 font-bold"
|
||||||
>
|
>
|
||||||
بستن ✕
|
بستن ✕
|
||||||
</button>
|
</button>
|
||||||
@ -558,42 +541,19 @@ export default function ArchivePage({
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 relative">
|
<main className="flex-1 relative">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between mb-6 gap-4">
|
<div className="flex flex-col md: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>
|
<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">
|
||||||
<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" />
|
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="جستجوی محصول..."
|
placeholder="جستجوی محصول..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Bar with X Dismiss Cross Buttons */}
|
{/* Active Filters Bar with X Dismiss Cross Buttons */}
|
||||||
{(selectedCategory !== "all" || selectedPet !== "all" || activeSymptoms.length > 0 || searchQuery) && (
|
{(selectedCategory !== "all" || selectedPet !== "all" || activeSymptoms.length > 0 || searchQuery) && (
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,248 +0,0 @@
|
|||||||
"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 (
|
return (
|
||||||
<div className="min-h-[80vh] flex items-center justify-center p-6" dir="rtl">
|
<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="max-w-lg w-full text-center">
|
||||||
<div className="relative mb-12 overflow-hidden">
|
<div className="relative mb-12">
|
||||||
<div className="text-[7rem] sm:text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div>
|
<div className="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="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">
|
<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" />
|
<FileQuestion className="w-16 h-16 text-canina-blue" />
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { toast } from "sonner";
|
|||||||
import HeaderButton from "./HeaderButton";
|
import HeaderButton from "./HeaderButton";
|
||||||
import AuthModal from "./AuthModal";
|
import AuthModal from "./AuthModal";
|
||||||
import PrescriptionUploadModal from "./PrescriptionUploadModal";
|
import PrescriptionUploadModal from "./PrescriptionUploadModal";
|
||||||
import TickerBanner from "./TickerBanner";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { productService } from "../lib/services/productService";
|
import { productService } from "../lib/services/productService";
|
||||||
|
|
||||||
@ -133,8 +132,11 @@ export default function Header({
|
|||||||
<PrescriptionUploadModal isOpen={isPrescriptionModalOpen} onClose={() => setIsPrescriptionModalOpen(false)} />
|
<PrescriptionUploadModal isOpen={isPrescriptionModalOpen} onClose={() => setIsPrescriptionModalOpen(false)} />
|
||||||
|
|
||||||
<div className="sticky top-0 z-50 w-full shadow-md font-vazir" dir="rtl">
|
<div className="sticky top-0 z-50 w-full shadow-md font-vazir" dir="rtl">
|
||||||
{/* Sliding Announcement Ticker Banner */}
|
{/* Top Free Shipping Notice */}
|
||||||
<TickerBanner />
|
<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>
|
||||||
|
|
||||||
<header className="bg-white border-b border-medical-gray-200">
|
<header className="bg-white border-b border-medical-gray-200">
|
||||||
{/* TOP ROW: Logo + Search + Primary Actions */}
|
{/* TOP ROW: Logo + Search + Primary Actions */}
|
||||||
@ -398,22 +400,14 @@ export default function Header({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Mobile Navigation Drawer & Backdrop */}
|
{/* Mobile Navigation Drawer */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<>
|
|
||||||
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
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"
|
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">
|
<form onSubmit={handleSearch} className="relative mb-4">
|
||||||
<input
|
<input
|
||||||
@ -447,7 +441,6 @@ export default function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,64 +33,6 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
|||||||
}
|
}
|
||||||
}, [step]);
|
}, [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
|
// Reset modal state when closed or opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@ -137,7 +79,26 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
|||||||
|
|
||||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleVerifyOtpWithCode(otpCode);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResendOtp = async () => {
|
const handleResendOtp = async () => {
|
||||||
@ -273,11 +234,9 @@ export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdviso
|
|||||||
ref={otpInputRef}
|
ref={otpInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={5}
|
maxLength={5}
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder="کد ۵ رقمی"
|
placeholder="کد ۵ رقمی"
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
onChange={handleOtpInputChange}
|
onChange={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
onPaste={handleOtpPaste}
|
|
||||||
disabled={isLoading}
|
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]"
|
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]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
"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,6 +72,13 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
|||||||
}
|
}
|
||||||
}, [initialView]);
|
}, [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 [activePetTab, setActivePetTab] = useState<"health" | "orders">("health");
|
||||||
const [petToDelete, setPetToDelete] = useState<GlobalPetProfile | null>(null);
|
const [petToDelete, setPetToDelete] = useState<GlobalPetProfile | null>(null);
|
||||||
|
|
||||||
@ -114,10 +121,54 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
|||||||
consumptions: []
|
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(() => {
|
const recommendedProducts = useMemo(() => {
|
||||||
if (!activePet || products.length === 0) return [];
|
if (!activePet || products.length === 0) return [];
|
||||||
|
|
||||||
const picks: { product: Product, reason?: string }[] = [];
|
let picks: { product: Product, reason?: string }[] = [];
|
||||||
|
|
||||||
// Priority 1: Match activePet.medicalConditions directly against product.symptoms
|
// Priority 1: Match activePet.medicalConditions directly against product.symptoms
|
||||||
if (activePet.medicalConditions && activePet.medicalConditions.length > 0) {
|
if (activePet.medicalConditions && activePet.medicalConditions.length > 0) {
|
||||||
@ -169,50 +220,6 @@ export default function PetProfile({ initialView, advisorNeed }: {
|
|||||||
setStep(1);
|
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 = () => {
|
const startEdit = () => {
|
||||||
if (activePet) {
|
if (activePet) {
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -249,14 +256,6 @@ 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
|
// 1. Pet Index Page
|
||||||
if (view === "index") {
|
if (view === "index") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
"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,7 +4,6 @@ import { motion, AnimatePresence } from "motion/react";
|
|||||||
import { X, CreditCard, Sparkles, CheckCircle2, TrendingUp, DollarSign } from "lucide-react";
|
import { X, CreditCard, Sparkles, CheckCircle2, TrendingUp, DollarSign } from "lucide-react";
|
||||||
import { toPersian, cn } from "../lib/utils";
|
import { toPersian, cn } from "../lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useUserStore } from "../lib/store/userStore";
|
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -38,10 +37,6 @@ export default function TopUpModal({ isOpen, onClose, onConfirm }: TopUpModalPro
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!useUserStore.getState().isLoggedIn) {
|
|
||||||
toast.error("جهت افزایش موجودی کیف پول لطفاً ابتدا وارد حساب کاربری خود شوید.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const finalAmount = parseInt(amount);
|
const finalAmount = parseInt(amount);
|
||||||
if (isNaN(finalAmount) || finalAmount < 10000) {
|
if (isNaN(finalAmount) || finalAmount < 10000) {
|
||||||
toast.error("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است");
|
toast.error("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است");
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion, AnimatePresence } 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 { 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 { OrderRowSkeleton } from "./Skeleton";
|
||||||
import { useUserStore, Address } from "../lib/store/userStore";
|
import { useUserStore, Address } from "../lib/store/userStore";
|
||||||
import { useCartStore } from "../lib/store/cartStore";
|
import { useCartStore } from "../lib/store/cartStore";
|
||||||
import { toPersian, toEnglishDigits, cn } from "../lib/utils";
|
import { toPersian, cn } from "../lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import OrderDetailsModal from "./OrderDetailsModal";
|
import OrderDetailsModal from "./OrderDetailsModal";
|
||||||
import AddressModal from "./AddressModal";
|
import AddressModal from "./AddressModal";
|
||||||
@ -16,15 +16,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
export default function UserDashboard() {
|
export default function UserDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { profile, isLoggedIn, logout, updateProfile, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
const { profile, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isLoggedIn && typeof window !== "undefined") {
|
|
||||||
router.push("/");
|
|
||||||
toast.error("برای دسترسی به داشبورد لطفاً ابتدا وارد حساب کاربری خود شوید.");
|
|
||||||
}
|
|
||||||
}, [isLoggedIn, router]);
|
|
||||||
|
|
||||||
const { orders } = useCartStore();
|
const { orders } = useCartStore();
|
||||||
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
|
const [activeTab, setActiveTab] = React.useState<"profile" | "orders" | "wallet" | "addresses" | "tickets">("profile");
|
||||||
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
const [isLoadingOrders, setIsLoadingOrders] = useState(false);
|
||||||
@ -32,16 +24,8 @@ export default function UserDashboard() {
|
|||||||
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
|
// Fetch fresh profile data (which includes orders) whenever orders tab is opened
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (activeTab === "orders") {
|
if (activeTab === "orders") {
|
||||||
let isSubscribed = true;
|
setIsLoadingOrders(true);
|
||||||
Promise.resolve().then(() => {
|
useUserStore.getState().fetchProfile().finally(() => setIsLoadingOrders(false));
|
||||||
if (isSubscribed) setIsLoadingOrders(true);
|
|
||||||
});
|
|
||||||
useUserStore.getState().fetchProfile().finally(() => {
|
|
||||||
if (isSubscribed) setIsLoadingOrders(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||||
@ -58,41 +42,36 @@ export default function UserDashboard() {
|
|||||||
// Profile State
|
// Profile State
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
const [profileForm, setProfileForm] = useState(() => ({
|
const [profileForm, setProfileForm] = useState({
|
||||||
firstName: profile.firstName || "",
|
firstName: profile.firstName || "",
|
||||||
lastName: profile.lastName || "",
|
lastName: profile.lastName || "",
|
||||||
email: profile.email || "",
|
email: profile.email || "",
|
||||||
mobile: profile.mobile || ""
|
mobile: profile.mobile || ""
|
||||||
}));
|
});
|
||||||
|
|
||||||
// Sync profile data on change
|
// Sync profile data on change
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let isSubscribed = true;
|
|
||||||
Promise.resolve().then(() => {
|
|
||||||
if (isSubscribed) {
|
|
||||||
setProfileForm({
|
setProfileForm({
|
||||||
firstName: profile.firstName || "",
|
firstName: profile.firstName || "",
|
||||||
lastName: profile.lastName || "",
|
lastName: profile.lastName || "",
|
||||||
email: profile.email || "",
|
email: profile.email || "",
|
||||||
mobile: profile.mobile || ""
|
mobile: profile.mobile || ""
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
};
|
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
const cleanPhone = profileForm.mobile.trim();
|
||||||
|
if (!/^09\d{9}$/.test(cleanPhone)) {
|
||||||
|
toast.error("شماره موبایل وارد شده معتبر نیست (باید ۱۱ رقم باشد و با ۰۹ شروع شود)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
const cleanPhone = toEnglishDigits(profileForm.mobile);
|
await useUserStore.getState().updateProfile({
|
||||||
await updateProfile({
|
|
||||||
firstName: profileForm.firstName,
|
firstName: profileForm.firstName,
|
||||||
lastName: profileForm.lastName,
|
lastName: profileForm.lastName,
|
||||||
email: profileForm.email,
|
email: profileForm.email,
|
||||||
@ -100,8 +79,8 @@ export default function UserDashboard() {
|
|||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
toast.error("خطا در ویرایش اطلاعات");
|
toast.error(err.message || "خطا در ویرایش اطلاعات");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingProfile(false);
|
setIsSavingProfile(false);
|
||||||
}
|
}
|
||||||
@ -119,7 +98,7 @@ export default function UserDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
await addAddress(addr);
|
await addAddress(addr);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("خطا در ثبت آدرس");
|
toast.error("خطا در ثبت آدرس");
|
||||||
}
|
}
|
||||||
setEditingAddress(null);
|
setEditingAddress(null);
|
||||||
@ -135,7 +114,7 @@ export default function UserDashboard() {
|
|||||||
try {
|
try {
|
||||||
await deleteAddress(addressToDelete.id);
|
await deleteAddress(addressToDelete.id);
|
||||||
toast.success("آدرس با موفقیت حذف شد");
|
toast.success("آدرس با موفقیت حذف شد");
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("خطا در حذف آدرس");
|
toast.error("خطا در حذف آدرس");
|
||||||
}
|
}
|
||||||
setAddressToDelete(null);
|
setAddressToDelete(null);
|
||||||
@ -378,7 +357,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-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">
|
<div className="text-[10px] font-bold text-medical-gray-400 flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3 opacity-30" />
|
<Calendar className="w-3 h-3 opacity-30" />
|
||||||
{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' }))}
|
{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' }))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -485,7 +464,7 @@ export default function UserDashboard() {
|
|||||||
try {
|
try {
|
||||||
await setDefaultAddress(addr.id);
|
await setDefaultAddress(addr.id);
|
||||||
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
toast.success("آدرس پیشفرض با موفقیت تغییر کرد");
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("خطا در تغییر آدرس پیشفرض");
|
toast.error("خطا در تغییر آدرس پیشفرض");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -535,28 +514,18 @@ export default function UserDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full md:w-auto">
|
<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
|
<button
|
||||||
onClick={() => {
|
disabled
|
||||||
if (profile.walletBalance < 50000) {
|
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"
|
||||||
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" />
|
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
برداشت وجه
|
برداشت وجه
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setIsTopUpModalOpen(true)}
|
onClick={() => setIsTopUpModalOpen(true)}
|
||||||
className="flex-1 md:w-40 h-12 sm:h-16 bg-white text-canina-blue rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 hover:bg-medical-gray-900 hover:text-white transition-all shadow-xl shadow-black/10 group/topup"
|
className="flex-1 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,24 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Play, Star, ShieldCheck, X } from "lucide-react";
|
import { Play, PlayCircle, Star, ShieldCheck, X } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
import { videoService, Video } from "../lib/services/videoService";
|
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 = [
|
const FALLBACK_VIDEOS = [
|
||||||
{
|
{
|
||||||
id: "v1",
|
id: "v1",
|
||||||
@ -49,10 +38,11 @@ const FALLBACK_VIDEOS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function VetGallery({ testimonials = [] }: { testimonials?: TestimonialItem[] }) {
|
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
|
||||||
|
const router = useRouter();
|
||||||
const getText = useSettingsStore(state => state.getText);
|
const getText = useSettingsStore(state => state.getText);
|
||||||
const [apiVideos, setApiVideos] = useState<Video[]>([]);
|
const [apiVideos, setApiVideos] = useState<Video[]>([]);
|
||||||
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
const [selectedVideo, setSelectedVideo] = useState<any>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
|
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
|
||||||
@ -65,13 +55,13 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: Testi
|
|||||||
const displayItems = testimonials.length > 0
|
const displayItems = testimonials.length > 0
|
||||||
? testimonials.map((t, idx) => ({
|
? testimonials.map((t, idx) => ({
|
||||||
id: t.id || `t-${idx}`,
|
id: t.id || `t-${idx}`,
|
||||||
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : (t.vetName || ''),
|
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : t.vetName,
|
||||||
doctor: t.vetName || '',
|
doctor: t.vetName,
|
||||||
duration: "۰۲:۰۰",
|
duration: "۰۲:۰۰",
|
||||||
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
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",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: t.quote || '',
|
description: t.quote,
|
||||||
quote: t.quote || ''
|
quote: t.quote
|
||||||
}))
|
}))
|
||||||
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
||||||
|
|
||||||
@ -112,7 +102,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: Testi
|
|||||||
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
|
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
|
||||||
<SafeImage
|
<SafeImage
|
||||||
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
|
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
|
||||||
alt={video.title || video.vetName || 'ویدیو دامپزشک'}
|
alt={video.title || video.vetName}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
imgClassName="object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
|
imgClassName="object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
|
||||||
/>
|
/>
|
||||||
@ -127,7 +117,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: Testi
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 ? video.quote.substring(0, 40) + '...' : '')}
|
{video.title || video.quote?.substring(0, 40) + '...'}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-2 text-medical-gray-500 font-bold text-sm font-vazir">
|
<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" />
|
<ShieldCheck className="w-4 h-4 text-canina-blue" />
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { ChevronRight, PlayCircle, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||||
import SafeImage from "./SafeImage";
|
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 { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import CartDrawer from '../CartDrawer';
|
import CartDrawer from '../CartDrawer';
|
||||||
import { useCartStore } from '../../lib/store/cartStore';
|
import { useCartStore } from '../../store/cartStore';
|
||||||
import { productService } from '../../lib/services/productService';
|
import { productService } from '../../services/productService';
|
||||||
|
|
||||||
vi.mock('../../lib/store/cartStore', () => ({
|
vi.mock('../../store/cartStore', () => ({
|
||||||
useCartStore: vi.fn(),
|
useCartStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/services/productService', () => ({
|
vi.mock('../../services/productService', () => ({
|
||||||
productService: {
|
productService: {
|
||||||
getProducts: vi.fn(),
|
getProducts: vi.fn(),
|
||||||
},
|
},
|
||||||
@ -28,7 +28,7 @@ const mockProduct = {
|
|||||||
describe('CartDrawer', () => {
|
describe('CartDrawer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(productService.getProducts).mockResolvedValue({ data: [] } as any);
|
vi.mocked(productService.getProducts).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty cart state when no items in cart', () => {
|
it('renders empty cart state when no items in cart', () => {
|
||||||
@ -44,7 +44,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useCartStore>);
|
} as any);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useCartStore>);
|
} as any);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ describe('CartDrawer', () => {
|
|||||||
coupon: null,
|
coupon: null,
|
||||||
applyCoupon: vi.fn(),
|
applyCoupon: vi.fn(),
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useCartStore>);
|
} as any);
|
||||||
|
|
||||||
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
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 { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FeaturedProducts from '../FeaturedProducts';
|
import FeaturedProducts from '../FeaturedProducts';
|
||||||
import { productService } from '../../lib/services/productService';
|
import { productService } from '../../services/productService';
|
||||||
import { usePetStore } from '../../lib/store/usePetStore';
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
|
|
||||||
vi.mock('../../lib/services/productService', () => ({
|
vi.mock('../../services/productService', () => ({
|
||||||
productService: {
|
productService: {
|
||||||
getFeaturedProducts: vi.fn(),
|
getFeaturedProducts: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/store/usePetStore', () => ({
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
usePetStore: vi.fn(),
|
usePetStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ describe('FeaturedProducts', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
getActivePet: () => null,
|
getActivePet: () => null,
|
||||||
} as unknown as ReturnType<typeof usePetStore>);
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders loading skeletons initially', () => {
|
it('renders loading skeletons initially', () => {
|
||||||
@ -48,7 +48,7 @@ describe('FeaturedProducts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders products once loaded', async () => {
|
it('renders products once loaded', async () => {
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -59,7 +59,7 @@ describe('FeaturedProducts', () => {
|
|||||||
|
|
||||||
it('calls onProductClick when product card is clicked', async () => {
|
it('calls onProductClick when product card is clicked', async () => {
|
||||||
const handleProductClick = vi.fn();
|
const handleProductClick = vi.fn();
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -72,7 +72,7 @@ describe('FeaturedProducts', () => {
|
|||||||
|
|
||||||
it('calls onShopNavigate when navigation link is clicked', async () => {
|
it('calls onShopNavigate when navigation link is clicked', async () => {
|
||||||
const handleShopNavigate = vi.fn();
|
const handleShopNavigate = vi.fn();
|
||||||
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
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 { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Header from '../Header';
|
import Header from '../Header';
|
||||||
import { useCartStore } from '../../lib/store/cartStore';
|
import { useCartStore } from '../../store/cartStore';
|
||||||
import { usePetStore } from '../../lib/store/usePetStore';
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
import { useUserStore } from '../../lib/store/userStore';
|
import { useUserStore } from '../../store/userStore';
|
||||||
|
|
||||||
vi.mock('../../lib/store/cartStore', () => ({
|
vi.mock('../../store/cartStore', () => ({
|
||||||
useCartStore: vi.fn(),
|
useCartStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/store/usePetStore', () => ({
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
usePetStore: vi.fn(),
|
usePetStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/store/userStore', () => ({
|
vi.mock('../../store/userStore', () => ({
|
||||||
useUserStore: vi.fn(),
|
useUserStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -25,21 +25,21 @@ describe('Header', () => {
|
|||||||
|
|
||||||
vi.mocked(useCartStore).mockReturnValue({
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
getTotalItems: () => 3,
|
getTotalItems: () => 3,
|
||||||
} as unknown as ReturnType<typeof useCartStore>);
|
} as any);
|
||||||
|
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
pets: [],
|
pets: [],
|
||||||
activePetId: null,
|
activePetId: null,
|
||||||
setActivePet: vi.fn(),
|
setActivePet: vi.fn(),
|
||||||
getActivePet: () => null,
|
getActivePet: () => null,
|
||||||
} as unknown as ReturnType<typeof usePetStore>);
|
} as any);
|
||||||
|
|
||||||
vi.mocked(useUserStore).mockReturnValue({
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
role: 'User_Guest',
|
role: 'User_Guest',
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
profile: { firstName: '' },
|
profile: { firstName: '' },
|
||||||
} as unknown as ReturnType<typeof useUserStore>);
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders brand name and login button when guest', () => {
|
it('renders brand name and login button when guest', () => {
|
||||||
@ -65,14 +65,14 @@ describe('Header', () => {
|
|||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
profile: { firstName: 'کوروش' },
|
profile: { firstName: 'کوروش' },
|
||||||
} as unknown as ReturnType<typeof useUserStore>);
|
} as any);
|
||||||
|
|
||||||
vi.mocked(usePetStore).mockReturnValue({
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
||||||
activePetId: 'pet-1',
|
activePetId: 'pet-1',
|
||||||
setActivePet: vi.fn(),
|
setActivePet: vi.fn(),
|
||||||
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
||||||
} as unknown as ReturnType<typeof usePetStore>);
|
} as any);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Hero from '../Hero';
|
import Hero from '../Hero';
|
||||||
import { useSettingsStore } from '../../lib/store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
vi.mock('../../lib/store/settingsStore', () => ({
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
useSettingsStore: vi.fn(),
|
useSettingsStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ describe('Hero', () => {
|
|||||||
return fallback;
|
return fallback;
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
render(<Hero onShopNavigate={vi.fn()} />);
|
render(<Hero onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
@ -31,8 +31,8 @@ describe('Hero', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
||||||
const mockGetText = vi.fn().mockImplementation((_key, fallback) => fallback);
|
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>);
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
const handleShopNavigate = vi.fn();
|
const handleShopNavigate = vi.fn();
|
||||||
render(<Hero onShopNavigate={handleShopNavigate} />);
|
render(<Hero onShopNavigate={handleShopNavigate} />);
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Tooltip from '../Tooltip';
|
import Tooltip from '../Tooltip';
|
||||||
import { useSettingsStore } from '../../lib/store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
vi.mock('../../lib/store/settingsStore', () => ({
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
useSettingsStore: vi.fn(),
|
useSettingsStore: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -756,7 +756,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
contraindications: ["مصرف همزمان ویتامین D2 مجاز نیست"],
|
contraindications: ["مصرف همزمان ویتامین D2 مجاز نیست"],
|
||||||
calculateDosage: (weight, isPregnant) => {
|
calculateDosage: (weight, isPregnant) => {
|
||||||
const rate = isPregnant ? 3 : 2;
|
const rate = isPregnant ? 3 : 2;
|
||||||
const qty = (weight / 10) * rate;
|
let qty = (weight / 10) * rate;
|
||||||
return { quantity: Math.round(qty), unit: "قرص", description: isPregnant ? "دوز بارداری/شیردهی" : "دوز معمول" };
|
return { quantity: Math.round(qty), unit: "قرص", description: isPregnant ? "دوز بارداری/شیردهی" : "دوز معمول" };
|
||||||
},
|
},
|
||||||
analysis: { "کلسیم": "۲۲٪" },
|
analysis: { "کلسیم": "۲۲٪" },
|
||||||
@ -788,7 +788,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
symptoms: ["خستگی مزمن", "رشد نامتناسب", "استرس"],
|
symptoms: ["خستگی مزمن", "رشد نامتناسب", "استرس"],
|
||||||
suitableFor: "هر دو",
|
suitableFor: "هر دو",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
const qty = Math.max(1, weight / 10);
|
let qty = Math.max(1, weight / 10);
|
||||||
return { quantity: Math.round(qty), unit: "قرص", description: "روزانه" };
|
return { quantity: Math.round(qty), unit: "قرص", description: "روزانه" };
|
||||||
},
|
},
|
||||||
analysis: { "ویتامینها": "A تا K3 کامل" },
|
analysis: { "ویتامینها": "A تا K3 کامل" },
|
||||||
@ -853,7 +853,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
symptoms: ["بینی روشن", "پوشش کدر", "کمبود ید"],
|
symptoms: ["بینی روشن", "پوشش کدر", "کمبود ید"],
|
||||||
suitableFor: "هر دو",
|
suitableFor: "هر دو",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
const tablets = (weight / 10) * 2;
|
let tablets = (weight / 10) * 2;
|
||||||
return { quantity: Math.round(tablets), unit: "قرص", description: "روزانه" };
|
return { quantity: Math.round(tablets), unit: "قرص", description: "روزانه" };
|
||||||
},
|
},
|
||||||
analysis: { "ید": "غنی", "لیزین": "+" },
|
analysis: { "ید": "غنی", "لیزین": "+" },
|
||||||
@ -1024,7 +1024,7 @@ export const PRODUCTS: Product[] = [
|
|||||||
suitableFor: "گربه",
|
suitableFor: "گربه",
|
||||||
onSetOfAction: "هفته دوم",
|
onSetOfAction: "هفته دوم",
|
||||||
calculateDosage: (weight) => {
|
calculateDosage: (weight) => {
|
||||||
const tsp = weight > 6 ? 1 : 0.5;
|
let tsp = weight > 6 ? 1 : 0.5;
|
||||||
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
|
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
|
||||||
},
|
},
|
||||||
analysis: { "تورین": "۷۰۰ گرم/kg" },
|
analysis: { "تورین": "۷۰۰ گرم/kg" },
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export function useNetworkStatus() {
|
export function useNetworkStatus() {
|
||||||
const [isOnline, setIsOnline] = useState(() =>
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
typeof navigator !== 'undefined' ? navigator.onLine : true
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
||||||
|
setIsOnline(navigator.onLine);
|
||||||
|
}
|
||||||
|
|
||||||
const handleOnline = () => setIsOnline(true);
|
const handleOnline = () => setIsOnline(true);
|
||||||
const handleOffline = () => setIsOnline(false);
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'sonner';
|
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');
|
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
||||||
|
|
||||||
@ -9,7 +8,7 @@ export interface ApiErrorPayload {
|
|||||||
statusCode: number;
|
statusCode: number;
|
||||||
message: string;
|
message: string;
|
||||||
code: string;
|
code: string;
|
||||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
@ -46,28 +45,20 @@ api.interceptors.response.use(
|
|||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||||
|
|
||||||
// Handle 401 Unauthorized globally - trigger Auth Modal
|
// Handle 401 Unauthorized globally
|
||||||
if (status === 401) {
|
if (status === 401 || status === 403) {
|
||||||
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('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
try {
|
try {
|
||||||
|
const { useUserStore } = require('../store/userStore');
|
||||||
useUserStore.getState().logout();
|
useUserStore.getState().logout();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if store not ready
|
// Ignore if store not ready
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show formatted Farsi toast if not explicitly suppressed and not 404
|
// Show formatted Farsi toast if not explicitly suppressed
|
||||||
if (!error.config?.hideErrorToast && status !== 404) {
|
if (!error.config?.hideErrorToast) {
|
||||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||||
const firstDetailMessage = responseData.details[0]?.message || farsiMessage;
|
const firstDetailMessage = responseData.details[0]?.message || farsiMessage;
|
||||||
toast.error(firstDetailMessage);
|
toast.error(firstDetailMessage);
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export interface User {
|
|||||||
charityDonationTotal: number;
|
charityDonationTotal: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
pets?: unknown[];
|
pets?: any[];
|
||||||
orders?: unknown[];
|
orders?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@ -23,15 +23,6 @@ export interface AuthResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErr {
|
|
||||||
response?: {
|
|
||||||
status?: number;
|
|
||||||
data?: {
|
|
||||||
message?: string | string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private static instance: AuthService;
|
private static instance: AuthService;
|
||||||
|
|
||||||
@ -51,9 +42,8 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/send-otp', { phoneNumber });
|
const response = await api.post('/auth/send-otp', { phoneNumber });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در ارسال کد تایید';
|
||||||
const message = err.response?.data?.message || 'خطا در ارسال کد تایید';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,9 +59,8 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'کد تایید نامعتبر است';
|
||||||
const message = err.response?.data?.message || 'کد تایید نامعتبر است';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -79,7 +68,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Register with email/mobile and password
|
* Register with email/mobile and password
|
||||||
*/
|
*/
|
||||||
public async register(data: Record<string, unknown>): Promise<AuthResponse> {
|
public async register(data: any): Promise<AuthResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/register', data);
|
const response = await api.post('/auth/register', data);
|
||||||
const { data: resData } = response.data;
|
const { data: resData } = response.data;
|
||||||
@ -87,9 +76,8 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', resData.accessToken);
|
localStorage.setItem('accessToken', resData.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در ثبتنام';
|
||||||
const message = err.response?.data?.message || 'خطا در ثبتنام';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,9 +93,8 @@ export class AuthService {
|
|||||||
localStorage.setItem('accessToken', data.accessToken);
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
||||||
const message = err.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,12 +106,11 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/users/profile');
|
const response = await api.get('/users/profile');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||||
if (err.response?.status === 401 || err.response?.status === 403) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const message = err.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
const message = error.response?.data?.message || 'خطا در دریافت اطلاعات کاربری';
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,9 +122,8 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.patch('/users/profile', profileData);
|
const response = await api.patch('/users/profile', profileData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
||||||
const message = err.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import api from './api';
|
import api from './api';
|
||||||
|
import { CartItem } from '../store/cartStore';
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
productId: string | null;
|
productId: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
product?: Record<string, unknown>;
|
product?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
@ -20,14 +21,6 @@ export interface Order {
|
|||||||
orderItems: OrderItem[];
|
orderItems: OrderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErr {
|
|
||||||
response?: {
|
|
||||||
data?: {
|
|
||||||
message?: string | string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
private static instance: OrderService;
|
private static instance: OrderService;
|
||||||
|
|
||||||
@ -57,9 +50,8 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.post('/orders', orderData);
|
const response = await api.post('/orders', orderData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در ثبت سفارش';
|
||||||
const message = err.response?.data?.message || 'خطا در ثبت سفارش';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,9 +63,8 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/orders');
|
const response = await api.get('/orders');
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
||||||
const message = err.response?.data?.message || 'خطا در دریافت لیست سفارشها';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,9 +76,8 @@ export class OrderService {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get(`/orders/${id}`);
|
const response = await api.get(`/orders/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
const err = error as ApiErr;
|
const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
||||||
const message = err.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
|
|
||||||
throw new Error(Array.isArray(message) ? message[0] : message);
|
throw new Error(Array.isArray(message) ? message[0] : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,19 +23,18 @@ export class ProductService {
|
|||||||
return ProductService.instance;
|
return ProductService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
|
private mapBackendToFrontend(data: any): Product {
|
||||||
const data = inputData as any;
|
|
||||||
// Find the local static product to inherit functions like calculateDosage
|
// Find the local static product to inherit functions like calculateDosage
|
||||||
const local = PRODUCTS.find(p =>
|
const local = PRODUCTS.find(p =>
|
||||||
p.artNo === data.artNo ||
|
p.artNo === data.artNo ||
|
||||||
p.id === data.productGroup ||
|
p.id === data.productGroup ||
|
||||||
(typeof data.slug === 'string' && data.slug.includes(p.id))
|
(data.slug && data.slug.includes(p.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
const safeParse = (val: unknown, fallback: unknown) => {
|
const safeParse = (val: any, fallback: any) => {
|
||||||
if (!val) return fallback;
|
if (!val) return fallback;
|
||||||
if (typeof val === 'string') {
|
if (typeof val === 'string') {
|
||||||
try { return JSON.parse(val); } catch { return fallback; }
|
try { return JSON.parse(val); } catch (e) { return fallback; }
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
@ -69,8 +68,8 @@ export class ProductService {
|
|||||||
? data.imageUrl
|
? data.imageUrl
|
||||||
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
|
: (local?.image || (data.imageUrl ? `/api${data.imageUrl}` : data.image || '')),
|
||||||
|
|
||||||
main_ingredients: data.ingredientList?.map((i: { ingredient: string }) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
main_ingredients: data.ingredientList?.map((i: any) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
|
||||||
symptoms: data.symptoms?.map((s: { symptom?: string }) => s.symptom || s) || [],
|
symptoms: data.symptoms?.map((s: any) => s.symptom || s) || [],
|
||||||
keyBenefits: safeParse(data.keyBenefits, []),
|
keyBenefits: safeParse(data.keyBenefits, []),
|
||||||
expectedResults: safeParse(data.expectedResults, []),
|
expectedResults: safeParse(data.expectedResults, []),
|
||||||
benefitsList: safeParse(data.benefitsList, []),
|
benefitsList: safeParse(data.benefitsList, []),
|
||||||
@ -91,23 +90,19 @@ export class ProductService {
|
|||||||
symptom?: string;
|
symptom?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'asc' | 'desc';
|
|
||||||
}): Promise<{ data: Product[]; meta: { total: number; page: number; lastPage: number; limit: number } }> {
|
}): Promise<{ data: Product[]; meta: { total: number; page: number; lastPage: number; limit: number } }> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
|
if (filters?.category && filters.category !== "all") params.append('category', filters.category);
|
||||||
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
|
if (filters?.petType && filters.petType !== "all") params.append('petType', filters.petType);
|
||||||
if (filters?.query) params.append('search', filters.query);
|
if (filters?.query) params.append('search', filters.query);
|
||||||
if (filters?.symptom) params.append('symptom', filters.symptom);
|
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('page', String(filters?.page || 1));
|
||||||
params.append('limit', String(filters?.limit || 12));
|
params.append('limit', String(filters?.limit || 12));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/products?${params.toString()}`);
|
const response = await api.get(`/products?${params.toString()}`);
|
||||||
return {
|
return {
|
||||||
data: response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
|
data: response.data.data.map((item: any) => this.mapBackendToFrontend(item)),
|
||||||
meta: response.data.meta,
|
meta: response.data.meta,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -139,7 +134,7 @@ export class ProductService {
|
|||||||
public async getFeaturedProducts(): Promise<Product[]> {
|
public async getFeaturedProducts(): Promise<Product[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/products?limit=30');
|
const response = await api.get('/products?limit=30');
|
||||||
const all = response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item));
|
const all = response.data.data.map((item: any) => this.mapBackendToFrontend(item));
|
||||||
const featured: Product[] = [];
|
const featured: Product[] = [];
|
||||||
const categoriesSeen = new Set<string>();
|
const categoriesSeen = new Set<string>();
|
||||||
|
|
||||||
|
|||||||
@ -32,11 +32,11 @@ const mockProduct = {
|
|||||||
dosage_logic: '1',
|
dosage_logic: '1',
|
||||||
benefits: '',
|
benefits: '',
|
||||||
symptoms: [],
|
symptoms: [],
|
||||||
suitableFor: 'سگ' as never,
|
suitableFor: 'سگ' as any,
|
||||||
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
||||||
analysis: {},
|
analysis: {},
|
||||||
feedingAdvice: '',
|
feedingAdvice: '',
|
||||||
specialist: null as never,
|
specialist: null as any,
|
||||||
image: '',
|
image: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -79,7 +79,6 @@ describe('userStore', () => {
|
|||||||
|
|
||||||
vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData);
|
vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
(globalThis as any)._testToken = 'test-token';
|
|
||||||
const store = useUserStore.getState();
|
const store = useUserStore.getState();
|
||||||
await store.fetchProfile();
|
await store.fetchProfile();
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@ interface CartStore {
|
|||||||
addOrder: (orderData: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
|
addOrder: (orderData: Omit<Order, 'id' | 'date' | 'status' | 'trackingNumber'>) => Promise<string>;
|
||||||
clearCart: () => void;
|
clearCart: () => void;
|
||||||
removeCoupon: () => void;
|
removeCoupon: () => void;
|
||||||
setOrders: (orders: Record<string, unknown>[]) => void;
|
setOrders: (orders: any[]) => void;
|
||||||
getTotalItems: () => number;
|
getTotalItems: () => number;
|
||||||
getSubtotal: () => number;
|
getSubtotal: () => number;
|
||||||
getDiscount: () => number;
|
getDiscount: () => number;
|
||||||
@ -120,7 +120,6 @@ export const useCartStore = create<CartStore>()(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const backendOrder = await orderService.createOrder(payload);
|
const backendOrder = await orderService.createOrder(payload);
|
||||||
const bo = backendOrder as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const newOrder: Order = {
|
const newOrder: Order = {
|
||||||
...orderData,
|
...orderData,
|
||||||
@ -128,15 +127,15 @@ export const useCartStore = create<CartStore>()(
|
|||||||
date: backendOrder.createdAt,
|
date: backendOrder.createdAt,
|
||||||
total: Number(backendOrder.totalAmount),
|
total: Number(backendOrder.totalAmount),
|
||||||
charityDonation: Number(backendOrder.charityDonation),
|
charityDonation: Number(backendOrder.charityDonation),
|
||||||
status: (['processing', 'shipped', 'delivered'].includes(backendOrder.status) ? backendOrder.status : 'processing') as Order['status'],
|
status: backendOrder.status as any,
|
||||||
isRefill: bo.isRefill !== undefined ? Boolean(bo.isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
isRefill: (backendOrder as any).isRefill !== undefined ? Boolean((backendOrder as any).isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
|
||||||
paymentMethod: (bo.paymentMethod as string) || orderData.paymentMethod,
|
paymentMethod: (backendOrder as any).paymentMethod || orderData.paymentMethod,
|
||||||
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
trackingNumber: backendOrder.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||||
};
|
};
|
||||||
|
|
||||||
set({ orders: [newOrder, ...get().orders] });
|
set({ orders: [newOrder, ...get().orders] });
|
||||||
return backendOrder.id;
|
return backendOrder.id;
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Order creation failed on backend:", error);
|
console.error("Order creation failed on backend:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -145,20 +144,20 @@ export const useCartStore = create<CartStore>()(
|
|||||||
removeCoupon: () => set({ coupon: null }),
|
removeCoupon: () => set({ coupon: null }),
|
||||||
setOrders: (backendOrders) => {
|
setOrders: (backendOrders) => {
|
||||||
const mappedOrders: Order[] = backendOrders.map(bo => ({
|
const mappedOrders: Order[] = backendOrders.map(bo => ({
|
||||||
id: String(bo.id),
|
id: bo.id,
|
||||||
date: String(bo.createdAt),
|
date: bo.createdAt,
|
||||||
items: (bo.orderItems as Array<{ product: Product; quantity: number }>)?.map((oi) => ({
|
items: bo.orderItems?.map((oi: any) => ({
|
||||||
product: oi.product,
|
product: oi.product,
|
||||||
quantity: oi.quantity
|
quantity: oi.quantity
|
||||||
})) || [],
|
})) || [],
|
||||||
total: Number(bo.totalAmount),
|
total: Number(bo.totalAmount),
|
||||||
charityDonation: Number(bo.charityDonation),
|
charityDonation: Number(bo.charityDonation),
|
||||||
status: (['processing', 'shipped', 'delivered'].includes(String(bo.status)) ? bo.status : 'processing') as Order['status'],
|
status: bo.status,
|
||||||
isRefill: Boolean(bo.isRefill),
|
isRefill: Boolean((bo as any).isRefill),
|
||||||
paymentMethod: bo.paymentMethod as string | undefined,
|
paymentMethod: (bo as any).paymentMethod,
|
||||||
petId: (bo.petId as string) || ((bo.pet as { id?: string })?.id),
|
petId: bo.petId || bo.pet?.id,
|
||||||
shippingAddress: bo.shippingAddress as string | undefined,
|
shippingAddress: bo.shippingAddress,
|
||||||
trackingNumber: (bo.trackingNumber as string) || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}`
|
||||||
}));
|
}));
|
||||||
set({ orders: mappedOrders });
|
set({ orders: mappedOrders });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,9 +4,9 @@ interface UIState {
|
|||||||
isCartOpen: boolean;
|
isCartOpen: boolean;
|
||||||
isLoginModalOpen: boolean;
|
isLoginModalOpen: boolean;
|
||||||
isB2BPortalOpen: boolean;
|
isB2BPortalOpen: boolean;
|
||||||
advisorData: Record<string, unknown> | null;
|
advisorData: any;
|
||||||
setCartOpen: (open: boolean) => void;
|
setCartOpen: (open: boolean) => void;
|
||||||
setLoginModalOpen: (open: boolean, advisorData?: Record<string, unknown> | null) => void;
|
setLoginModalOpen: (open: boolean, advisorData?: any) => void;
|
||||||
setB2BPortalOpen: (open: boolean) => void;
|
setB2BPortalOpen: (open: boolean) => void;
|
||||||
clearAdvisorData: () => void;
|
clearAdvisorData: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import api from "../services/api";
|
import api from "../services/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useUserStore } from "./userStore";
|
|
||||||
|
|
||||||
export interface Reminder {
|
export interface Reminder {
|
||||||
id: string;
|
id: string;
|
||||||
@ -55,7 +54,7 @@ interface PetStore {
|
|||||||
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
|
addReminder: (petId: string, reminder: Omit<Reminder, "id" | "completedDates">) => Promise<void>;
|
||||||
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
|
toggleReminder: (petId: string, reminderId: string, date: string) => Promise<void>;
|
||||||
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
|
addHealthLog: (petId: string, log: Omit<HealthLog, "id" | "date">) => Promise<void>;
|
||||||
setPets: (pets: Record<string, unknown>[]) => void;
|
setPets: (pets: any[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +125,7 @@ export const usePetStore = create<PetStore>()(
|
|||||||
getActivePet: () => {
|
getActivePet: () => {
|
||||||
const { pets, activePetId } = get();
|
const { pets, activePetId } = get();
|
||||||
try {
|
try {
|
||||||
|
const { useUserStore } = require("./userStore");
|
||||||
if (!useUserStore.getState().isLoggedIn) {
|
if (!useUserStore.getState().isLoggedIn) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -195,29 +195,29 @@ export const usePetStore = create<PetStore>()(
|
|||||||
const mappedPets: PetProfile[] = backendPets.map(bp => {
|
const mappedPets: PetProfile[] = backendPets.map(bp => {
|
||||||
const existingLocal = currentLocalPets.find(lp => lp.id === bp.id);
|
const existingLocal = currentLocalPets.find(lp => lp.id === bp.id);
|
||||||
return {
|
return {
|
||||||
id: String(bp.id),
|
id: bp.id,
|
||||||
name: String(bp.name),
|
name: bp.name,
|
||||||
type: (bp.type as PetProfile['type']) || "سگ",
|
type: bp.type as any,
|
||||||
breed: String(bp.breed || ''),
|
breed: bp.breed,
|
||||||
age: Number(bp.age || 0),
|
age: Number(bp.age),
|
||||||
weight: Number(bp.weight || 0),
|
weight: Number(bp.weight),
|
||||||
activityLevel: (bp.activityLevel as PetProfile['activityLevel']) || "متوسط",
|
activityLevel: bp.activityLevel as any,
|
||||||
medicalConditions: (bp.medicalConditions as Array<{ condition: string }>)?.map((mc) => mc.condition) || [],
|
medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [],
|
||||||
image: (bp.imageUrl as string) || undefined,
|
image: bp.imageUrl || undefined,
|
||||||
reminders: (bp.reminders as Array<{ id: string; title: string; time: string; frequency: Reminder['frequency']; productId?: string; completions?: Array<{ completedDate: string }> }>)?.map((r) => ({
|
reminders: bp.reminders?.map((r: any) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
time: r.time,
|
time: r.time,
|
||||||
frequency: r.frequency,
|
frequency: r.frequency as any,
|
||||||
productId: r.productId,
|
productId: r.productId,
|
||||||
completedDates: r.completions?.map((c) => c.completedDate) || []
|
completedDates: r.completions?.map((c: any) => c.completedDate) || []
|
||||||
})) || [],
|
})) || [],
|
||||||
logs: (bp.healthLogs as Array<{ id: string; loggedDate: string; appetite: HealthLog['appetite']; energy: HealthLog['energy']; digestion: HealthLog['digestion']; note?: string }>)?.map((hl) => ({
|
logs: bp.healthLogs?.map((hl: any) => ({
|
||||||
id: hl.id,
|
id: hl.id,
|
||||||
date: hl.loggedDate,
|
date: hl.loggedDate,
|
||||||
appetite: hl.appetite,
|
appetite: hl.appetite as any,
|
||||||
energy: hl.energy,
|
energy: hl.energy as any,
|
||||||
digestion: hl.digestion,
|
digestion: hl.digestion as any,
|
||||||
note: hl.note || undefined
|
note: hl.note || undefined
|
||||||
})) || [],
|
})) || [],
|
||||||
consumptions: existingLocal?.consumptions || []
|
consumptions: existingLocal?.consumptions || []
|
||||||
|
|||||||
@ -44,11 +44,9 @@ interface UserProfile {
|
|||||||
interface UserStore {
|
interface UserStore {
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
isAuthModalOpen: boolean;
|
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
setRole: (role: UserRole) => void;
|
setRole: (role: UserRole) => void;
|
||||||
setLoggedIn: (isLoggedIn: boolean) => void;
|
setLoggedIn: (isLoggedIn: boolean) => void;
|
||||||
setAuthModalOpen: (open: boolean) => void;
|
|
||||||
updateProfile: (profile: Partial<UserProfile>) => Promise<void>;
|
updateProfile: (profile: Partial<UserProfile>) => Promise<void>;
|
||||||
addAddress: (address: Address) => Promise<void>;
|
addAddress: (address: Address) => Promise<void>;
|
||||||
updateAddress: (id: string, address: Address) => Promise<void>;
|
updateAddress: (id: string, address: Address) => Promise<void>;
|
||||||
@ -64,7 +62,6 @@ export const useUserStore = create<UserStore>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
role: "User_Guest",
|
role: "User_Guest",
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
isAuthModalOpen: false,
|
|
||||||
profile: {
|
profile: {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
@ -77,7 +74,6 @@ export const useUserStore = create<UserStore>()(
|
|||||||
},
|
},
|
||||||
setRole: (role) => set({ role }),
|
setRole: (role) => set({ role }),
|
||||||
setLoggedIn: (isLoggedIn) => set({ isLoggedIn }),
|
setLoggedIn: (isLoggedIn) => set({ isLoggedIn }),
|
||||||
setAuthModalOpen: (open) => set({ isAuthModalOpen: open }),
|
|
||||||
updateProfile: async (updates) => {
|
updateProfile: async (updates) => {
|
||||||
try {
|
try {
|
||||||
const profileData = await authService.updateProfile(updates);
|
const profileData = await authService.updateProfile(updates);
|
||||||
@ -98,16 +94,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
addAddress: async (address) => {
|
addAddress: async (address) => {
|
||||||
try {
|
try {
|
||||||
// Remove ID so backend generates UUID
|
// Remove ID so backend generates UUID
|
||||||
const addressData = {
|
const { id, ...addressData } = address;
|
||||||
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 api.post('/users/addresses', addressData);
|
||||||
await useUserStore.getState().fetchProfile();
|
await useUserStore.getState().fetchProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,16 +104,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
},
|
},
|
||||||
updateAddress: async (id, updated) => {
|
updateAddress: async (id, updated) => {
|
||||||
try {
|
try {
|
||||||
const addressData = {
|
const { id: _, ...addressData } = updated;
|
||||||
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 api.patch(`/users/addresses/${id}`, addressData);
|
||||||
await useUserStore.getState().fetchProfile();
|
await useUserStore.getState().fetchProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -182,7 +160,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
},
|
},
|
||||||
fetchProfile: async () => {
|
fetchProfile: async () => {
|
||||||
try {
|
try {
|
||||||
const token = typeof window !== 'undefined' && window.localStorage ? localStorage.getItem('accessToken') : (globalThis as any)._testToken;
|
const token = localStorage.getItem('accessToken');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
try { usePetStore.getState().reset(); } catch {}
|
try { usePetStore.getState().reset(); } catch {}
|
||||||
try { localStorage.removeItem("canina-pets"); } catch {}
|
try { localStorage.removeItem("canina-pets"); } catch {}
|
||||||
@ -223,15 +201,14 @@ export const useUserStore = create<UserStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const pd = profileData as unknown as Record<string, unknown>;
|
|
||||||
const backendWallet = Number(profileData.walletBalance || 0);
|
const backendWallet = Number(profileData.walletBalance || 0);
|
||||||
const backendCharity = Number(profileData.charityDonationTotal || 0);
|
const backendCharity = Number(profileData.charityDonationTotal || 0);
|
||||||
const backendTransactions = (pd.walletTransactions as Array<{ id: string; type: string; amount: number; createdAt: string; status: string }>)?.map((t) => ({
|
const backendTransactions = (profileData as any).walletTransactions?.map((t: any) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
type: (t.type === 'deposit' ? 'top_up' : 'purchase') as Transaction['type'],
|
type: t.type === 'deposit' ? 'top_up' : 'purchase',
|
||||||
amount: Number(t.amount),
|
amount: Number(t.amount),
|
||||||
date: t.createdAt,
|
date: t.createdAt,
|
||||||
status: (t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending') as Transaction['status']
|
status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending'
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -244,7 +221,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
mobile: profileData.mobile || "",
|
mobile: profileData.mobile || "",
|
||||||
walletBalance: backendWallet,
|
walletBalance: backendWallet,
|
||||||
charityDonationTotal: backendCharity,
|
charityDonationTotal: backendCharity,
|
||||||
addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses,
|
addresses: (profileData as any).addresses?.length > 0 ? (profileData as any).addresses : state.profile.addresses,
|
||||||
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
|
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -253,11 +230,11 @@ export const useUserStore = create<UserStore>()(
|
|||||||
// Sync orders list to CartStore while preserving local petId & address mappings
|
// Sync orders list to CartStore while preserving local petId & address mappings
|
||||||
if (profileData.orders) {
|
if (profileData.orders) {
|
||||||
const currentLocalOrders = useCartStore.getState().orders;
|
const currentLocalOrders = useCartStore.getState().orders;
|
||||||
const mergedOrders = (profileData.orders as Record<string, unknown>[]).map((bo) => {
|
const mergedOrders = profileData.orders.map((bo: any) => {
|
||||||
const localMatch = currentLocalOrders.find(lo => lo.id === bo.id);
|
const localMatch = currentLocalOrders.find(lo => lo.id === bo.id);
|
||||||
return {
|
return {
|
||||||
...bo,
|
...bo,
|
||||||
petId: bo.petId || (bo.pet as { id?: string })?.id || localMatch?.petId,
|
petId: bo.petId || bo.pet?.id || localMatch?.petId,
|
||||||
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
|
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -265,7 +242,7 @@ export const useUserStore = create<UserStore>()(
|
|||||||
}
|
}
|
||||||
// Sync pets list to PetStore
|
// Sync pets list to PetStore
|
||||||
if (profileData.pets) {
|
if (profileData.pets) {
|
||||||
usePetStore.getState().setPets(profileData.pets as Record<string, unknown>[]);
|
usePetStore.getState().setPets(profileData.pets);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user profile:", error);
|
console.error("Failed to fetch user profile:", error);
|
||||||
|
|||||||
@ -3,12 +3,6 @@ export const toPersian = (n: number | string | undefined | null) => {
|
|||||||
return n.toString().replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
|
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)[]) {
|
export function cn(...classes: (string | boolean | undefined)[]) {
|
||||||
return classes.filter(Boolean).join(" ");
|
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,17 +20,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.9",
|
"eslint-config-next": "16.2.9",
|
||||||
"jsdom": "^29.1.1",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5"
|
||||||
"vitest": "^4.1.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "**/__tests__/**"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
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'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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