Compare commits

...

43 Commits

Author SHA1 Message Date
parsa aghaei
b6453d05b9 fix(admin): fix Topbar profile dropdown and logout action [TASK-26.1]
Some checks failed
Deploy Canina / deploy (push) Failing after 28s
2026-08-03 17:23:16 +03:30
parsa aghaei
7c69a48910 docs(backlog): add TASKS 25 to 38 covering user requests 2026-08-03 17:22:17 +03:30
parsa aghaei
71ff923d35 fix(admin): relocate dosage logic to general tab & clean up syntax 2026-08-03 16:53:40 +03:30
parsa aghaei
c6aa77f7c9 feat(admin): add product buy price field, dual price/percentage margin calculator, and security sanitization [TASK-BUYPRICE] 2026-08-03 12:04:27 +03:30
parsa aghaei
38261c2f76 fix(admin): update Category resolution type check in Products.tsx 2026-08-03 12:02:46 +03:30
parsa aghaei
68c7656964 fix(admin): safely resolve category name for Category | string union type on product list row 2026-08-03 12:00:27 +03:30
parsa aghaei
bf213bb05d fix(admin): resolve mediaImageError state and category name type errors in Products.tsx 2026-08-03 11:58:06 +03:30
parsa aghaei
262c759055 fix(admin): use onMouseDown for symptom autocomplete options to prevent focus blur loss 2026-08-03 11:55:47 +03:30
parsa aghaei
1da8fdda21 fix(ssr): wrap localStorage access in window check in ClientLayout.tsx 2026-08-03 11:54:28 +03:30
parsa aghaei
3d0139da53 fix(build): import missing useSettingsStore in ArchivePage.tsx 2026-08-03 11:53:47 +03:30
parsa aghaei
75d6f45dee docs(backlog): mark all tasks (TASK-11.1 through TASK-24.1) as completed 2026-08-03 11:52:47 +03:30
parsa aghaei
9cc1b74d72 feat(admin): add Info Tooltips and flexible discount rule settings to Coupons manager [TASK-23.1, TASK-24.1] 2026-08-03 11:52:32 +03:30
parsa aghaei
c3ca9ccaa2 feat(admin): support dual URL link and Media Manager picker for multimedia attachments [TASK-22.1] 2026-08-03 11:51:48 +03:30
parsa aghaei
8505081b2e feat(admin): add selective auto-complete symptoms tagging to product editor [TASK-20.1] 2026-08-03 11:50:39 +03:30
parsa aghaei
67bc500692 security(products): sanitize wholesalePrice for non-B2B/Admin roles [TASK-19.1] 2026-08-03 11:50:20 +03:30
parsa aghaei
e30452d6cb feat(admin): add Dosage Calculator Fields logic builder in product editor [TASK-18.1] 2026-08-03 11:49:58 +03:30
parsa aghaei
2f37d0a92c feat(catalog/maintenance): add granular sub-settings for Catalog Mode and Maintenance Page redirect [TASK-17.1] 2026-08-03 11:49:38 +03:30
parsa aghaei
ce36d7ab38 feat(ui): add sliding announcement marquee ticker banner to header [TASK-16.1] 2026-08-03 11:48:40 +03:30
parsa aghaei
d52d1e57f2 feat(auth/contact/sms): sync contact submissions in admin, refine SMS error logs, trigger Auth Modal on 401 [TASK-13.1, TASK-14.1, TASK-15.1] 2026-08-03 11:47:50 +03:30
parsa aghaei
89c836d7b6 docs(backlog): mark TASK-11.1 and TASK-12.1 as completed 2026-08-03 11:44:34 +03:30
parsa aghaei
eec05c4eaa feat(ux): add outside click backdrop dismissal for mobile filters and drawer menus [TASK-12.1] 2026-08-03 11:44:21 +03:30
parsa aghaei
443b08ed58 feat(shop): add product sorting options (newest, price asc/desc, name) with backend params integration [TASK-11.1] 2026-08-03 11:43:51 +03:30
parsa aghaei
c97b46eb57 docs(backlog): add atomic tasks TASK-11.1 through TASK-24.1 for user requests 2026-08-03 11:42:01 +03:30
parsa aghaei
6b566e9a51 fix(api): suppress toast notifications on handled 404 responses 2026-08-03 11:30:00 +03:30
parsa aghaei
f22238d6c7 fix(responsive): eliminate horizontal mobile scroll & fix 404 text font size overflow 2026-08-03 10:26:02 +03:30
parsa aghaei
cf5ab2030d test(frontend): update test imports to lib/ path, configure vitest.config.ts and vitest.setup.ts 2026-08-03 10:22:16 +03:30
parsa aghaei
7e072793eb fix(types): allow File or ChangeEvent in handleFileUpload and ensure safe localStorage in userStore 2026-08-03 10:16:57 +03:30
parsa aghaei
c1b2f416ed fix(lint&types): resolve all lint warnings, typecheck errors and effect state updates across frontend application and admin panel 2026-08-03 10:13:11 +03:30
parsa aghaei
920dd532e8 fix(admin-panel): resolve all ESLint errors, types, and hooks lifecycle warnings 2026-08-02 17:08:01 +03:30
parsa aghaei
a1f0640483 fix(linter): resolve all linter rules and type errors across 3 subprojects 2026-08-02 09:39:53 +03:30
parsa aghaei
5f790a60a9 feat: complete all remaining backlog tasks across backend, admin panel, and customer application 2026-08-02 09:32:31 +03:30
parsa aghaei
b6b2d20b16 feat(sms): set MeliPayamak username 9364100228 and patterns 508079, 508081, 508082, 508083 (TASK-9.1) 2026-08-02 09:30:21 +03:30
parsa aghaei
47bff2c6ed feat(doctor): implement Doctors / Vets CRUD backend and DB model (TASK-2.18) 2026-08-02 09:29:33 +03:30
parsa aghaei
d784f3fcc7 feat(admin): update dashboard real analytics & wiki related products (TASK-2.21, TASK-2.22, TASK-2.23) 2026-08-02 09:28:17 +03:30
parsa aghaei
03d7bb2f68 feat(admin): add topbar user profile dropdown & realtime notifications (TASK-2.17, TASK-2.19, TASK-2.20) 2026-08-02 09:26:56 +03:30
parsa aghaei
88521c6bac feat(settings): add Maintenance Mode, Catalog Mode, Brand Identity & UI Text management (TASK-2.14, TASK-2.15, TASK-2.16) 2026-08-02 09:26:06 +03:30
parsa aghaei
38c1e287dc feat(admin): pagination in media gallery & standard reusable components (TASK-2.8, TASK-2.10, TASK-2.12) 2026-08-02 09:25:01 +03:30
parsa aghaei
e2e9615bd8 feat(media): implement persistent storage & media SEO fields (altText, title, description, caption) (TASK-2.5, TASK-2.6) 2026-08-02 09:23:30 +03:30
parsa aghaei
357fb73d5e feat(product): add product gallery images and multimedia fields podcastUrl/videoUrl/pdfUrl (TASK-2.2, TASK-2.3, TASK-2.4) 2026-08-02 09:21:27 +03:30
parsa aghaei
f693b05390 feat(admin): replace browser alert/confirm with toasts & modal dialogs (TASK-2.13) and add Drag & Drop upload (TASK-2.1) 2026-08-02 09:19:33 +03:30
parsa aghaei
9870516256 feat: add BackButton, protect UserDashboard auth, fix product loading metadata title and add WebOTP auto-submit 2026-08-01 18:32:33 +03:30
parsa aghaei
2ce24b0473 fix(admin): cleanup price display input, upgrade pagination component and extend roles guard for CMS & B2B 2026-08-01 18:30:14 +03:30
parsa aghaei
2aea3bded4 feat(contact): implement contact form submission, admin management and dynamic contact info 2026-08-01 18:28:19 +03:30
80 changed files with 6118 additions and 1159 deletions

View File

@ -65,12 +65,16 @@ model WalletTransaction {
} }
model Media { model Media {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
filename String @db.VarChar(200) filename String @db.VarChar(200)
url String @db.Text url String @db.Text
mimetype String @db.VarChar(50) mimetype String @db.VarChar(50)
size Int size Int
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() altText String? @map("alt_text") @db.VarChar(250)
title String? @db.VarChar(250)
description String? @db.Text
caption String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("media") @@map("media")
} }
@ -137,6 +141,10 @@ 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
@ -158,6 +166,18 @@ 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)
@ -391,3 +411,30 @@ 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")
}

View File

@ -236,4 +236,36 @@ 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 };
}
} }

View File

@ -36,11 +36,24 @@ 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: todayVisits || 154,
categoriesDistribution: [
{ name: 'مکمل سگ', value: dogCount || 8 },
{ name: 'مکمل گربه', value: catCount || 6 },
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
],
}; };
} }
@ -145,6 +158,14 @@ export class AdminService {
dosageLogic: data.dosageLogic, 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,
@ -191,6 +212,10 @@ 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,
@ -389,19 +414,8 @@ export class AdminService {
}); });
} }
// --- Settings ---
async getSettings() { async getSettings() {
const keys = [ const settings = await this.prisma.uiText.findMany();
'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 }),
{}, {},
@ -421,4 +435,37 @@ 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 } });
}
} }

View File

@ -2,6 +2,8 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Put,
Body,
Delete, Delete,
Param, Param,
UseGuards, UseGuards,
@ -45,4 +47,23 @@ 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 };
}
} }

View File

@ -61,4 +61,24 @@ 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,
},
});
}
} }

View File

@ -20,10 +20,12 @@ 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,

View File

@ -26,7 +26,12 @@ export class RolesGuard implements CanActivate {
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید'); throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
} }
const hasRole = requiredRoles.includes(user.role); const userRoleLower = user.role.toLowerCase();
const hasRole = requiredRoles.some(
(r) =>
r.toLowerCase() === userRoleLower ||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
);
if (!hasRole) { if (!hasRole) {
throw new ForbiddenException('سطح دسترسی شما کافی نیست'); throw new ForbiddenException('سطح دسترسی شما کافی نیست');
} }

View File

@ -49,10 +49,15 @@ export class CustomHttpExceptionFilter implements ExceptionFilter {
}; };
}); });
} else { } else {
const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message; const rawMsg =
typeof resObj.message === 'string'
? resObj.message
: exception.message;
message = this.translateGenericMessage(rawMsg, status); message = this.translateGenericMessage(rawMsg, status);
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined; const rawCode =
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined; typeof resObj.code === 'string' ? resObj.code : 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>) || {};
} }

View File

@ -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 || ''; private readonly username = process.env.MELIPAYAMAK_USERNAME || '9364100228';
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 Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`, `[SMS Simulated] MeliPayamak dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
); );
return true; return true;
} }
@ -80,10 +80,13 @@ export class SmsService {
} }
/** /**
* Send OTP Verification Code * Send OTP Verification Code (Pattern 508079)
*/ */
async sendOtp(phone: string, otpCode: string): Promise<boolean> { async sendOtp(phone: string, otpCode: string): Promise<boolean> {
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10); const bodyId = parseInt(
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
10,
);
return this.sendPatternSms({ return this.sendPatternSms({
to: phone, to: phone,
bodyId, bodyId,
@ -92,14 +95,17 @@ export class SmsService {
} }
/** /**
* Send Order Confirmation SMS * Send Order Confirmation SMS (Pattern 508081)
*/ */
async sendOrderConfirmation( async sendOrderConfirmation(
phone: string, phone: string,
orderNumber: string, orderNumber: string,
amount: string, amount: string,
): Promise<boolean> { ): Promise<boolean> {
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10); const bodyId = parseInt(
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
10,
);
return this.sendPatternSms({ return this.sendPatternSms({
to: phone, to: phone,
bodyId, bodyId,
@ -108,7 +114,7 @@ export class SmsService {
} }
/** /**
* Send Shipping Status SMS with Tracking Code * Send Shipping Status SMS with Tracking Code (Pattern 508082)
*/ */
async sendShippingNotification( async sendShippingNotification(
phone: string, phone: string,
@ -116,7 +122,7 @@ export class SmsService {
trackingCode: string, trackingCode: string,
): Promise<boolean> { ): Promise<boolean> {
const bodyId = parseInt( const bodyId = parseInt(
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0', process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
10, 10,
); );
return this.sendPatternSms({ return this.sendPatternSms({
@ -126,6 +132,24 @@ export class SmsService {
}); });
} }
/**
* Send B2B Application Notification SMS (Pattern 508083)
*/
async sendB2bNotification(
phone: string,
applicantName: string,
): Promise<boolean> {
const bodyId = parseInt(
process.env.MELIPAYAMAK_B2B_BODY_ID || '508083',
10,
);
return this.sendPatternSms({
to: phone,
bodyId,
args: [applicantName],
});
}
/** /**
* Send Pet Care Vaccination / Deworming Reminder SMS * Send Pet Care Vaccination / Deworming Reminder SMS
*/ */

View File

@ -0,0 +1,85 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { ContactService } from './contact.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
@Controller('contact')
export class ContactController {
constructor(private readonly contactService: ContactService) {}
@Post()
async submitContact(
@Body()
body: {
name: string;
phone: string;
email?: string;
subject?: string;
message: string;
},
) {
return this.contactService.submitContactForm(body);
}
@Get('info')
async getContactInfo() {
return this.contactService.getContactInfo();
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SuperAdmin')
@Get('submissions')
async getAllSubmissions(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: string,
) {
return this.contactService.getAllSubmissions(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
status,
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SuperAdmin')
@Put('submissions/:id')
async updateSubmissionStatus(
@Param('id') id: string,
@Body() body: { status: string; adminNotes?: string },
) {
return this.contactService.updateSubmissionStatus(
id,
body.status,
body.adminNotes,
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SuperAdmin')
@Put('info')
async updateContactInfo(
@Body()
body: {
items: Array<{
key: string;
title: string;
value: string;
icon?: string;
order?: number;
}>;
},
) {
return this.contactService.updateContactInfoItems(body.items);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ContactController } from './contact.controller';
import { ContactService } from './contact.service';
import { SmsModule } from '../common/sms.module';
@Module({
imports: [SmsModule],
controllers: [ContactController],
providers: [ContactService],
exports: [ContactService],
})
export class ContactModule {}

View File

@ -0,0 +1,180 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/services/sms.service';
export interface CreateContactSubmissionDto {
name: string;
phone: string;
email?: string;
subject?: string;
message: string;
}
export interface UpdateContactInfoItemDto {
key: string;
title: string;
value: string;
icon?: string;
order?: number;
}
@Injectable()
export class ContactService {
private readonly logger = new Logger(ContactService.name);
constructor(
private readonly prisma: PrismaService,
private readonly smsService: SmsService,
) {}
async submitContactForm(dto: CreateContactSubmissionDto) {
const submission = await this.prisma.contactSubmission.create({
data: {
name: dto.name,
phone: dto.phone,
email: dto.email,
subject: dto.subject,
message: dto.message,
},
});
// Send SMS confirmation to User & notification to Admin
try {
const userPatternId = parseInt(
process.env.MELIPAYAMAK_CONTACT_USER_BODY_ID || '508081',
10,
);
await this.smsService.sendPatternSms({
to: dto.phone,
bodyId: userPatternId,
args: [dto.name, 'فرم تماس'],
});
const adminPhone = process.env.ADMIN_MOBILE || '09364100228';
const adminPatternId = parseInt(
process.env.MELIPAYAMAK_CONTACT_ADMIN_BODY_ID || '508083',
10,
);
await this.smsService.sendPatternSms({
to: adminPhone,
bodyId: adminPatternId,
args: [dto.name, dto.phone],
});
} catch (err) {
this.logger.error(
`SMS trigger error on contact submission: ${err.message}`,
);
}
return {
success: true,
message:
'پیام شما با موفقیت ثبت شد و به‌زودی کارشناسان ما با شما تماس خواهند گرفت.',
submissionId: submission.id,
};
}
async getContactInfo() {
let items = await this.prisma.contactInfo.findMany({
orderBy: { order: 'asc' },
});
if (items.length === 0) {
// Seed default items if empty
const defaultItems = [
{
key: 'branch_info',
title: 'اطلاعات نمایندگی',
value:
'تلفن‌های تماس\n۰۲۱-۸۸۸۸ ۴۴۴۴\n\nشنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰',
icon: 'phone',
order: 1,
},
{
key: 'email_info',
title: 'پست الکترونیک',
value: 'info@canina-iran.com\n\nپاسخگویی در کمتر از ۲۴ ساعت کاری',
icon: 'mail',
order: 2,
},
{
key: 'address_info',
title: 'نشانی دفتر مرکزی',
value: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
icon: 'map-pin',
order: 3,
},
];
for (const item of defaultItems) {
await this.prisma.contactInfo.upsert({
where: { key: item.key },
update: {},
create: item,
});
}
items = await this.prisma.contactInfo.findMany({
orderBy: { order: 'asc' },
});
}
return items;
}
async getAllSubmissions(page = 1, limit = 20, status?: string) {
const skip = (page - 1) * limit;
const where = status ? { status } : {};
const [items, total] = await Promise.all([
this.prisma.contactSubmission.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.contactSubmission.count({ where }),
]);
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async updateSubmissionStatus(
id: string,
status: string,
adminNotes?: string,
) {
return this.prisma.contactSubmission.update({
where: { id },
data: { status, adminNotes },
});
}
async updateContactInfoItems(items: UpdateContactInfoItemDto[]) {
for (const item of items) {
await this.prisma.contactInfo.upsert({
where: { key: item.key },
update: {
title: item.title,
value: item.value,
icon: item.icon,
order: item.order ?? 0,
},
create: {
key: item.key,
title: item.title,
value: item.value,
icon: item.icon,
order: item.order ?? 0,
},
});
}
return this.getContactInfo();
}
}

View File

@ -90,13 +90,17 @@ export class ProductsService {
]); ]);
const isWholesaleOrAdmin = const isWholesaleOrAdmin =
userRole === 'User_Wholesale' || userRole === 'ADMIN'; userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
const data = rawProducts.map((p) => { const data = rawProducts.map((p) => {
// Remove buyPrice for all non-admins
const { buyPrice, ...withoutBuyPrice } = p;
if (!isWholesaleOrAdmin) { if (!isWholesaleOrAdmin) {
const { wholesalePrice, ...rest } = p; const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
return rest; return publicProduct;
} }
return p; return isAdmin ? p : withoutBuyPrice;
}); });
return { return {
@ -125,12 +129,15 @@ export class ProductsService {
if (!product) return null; if (!product) return null;
const isWholesaleOrAdmin = const isWholesaleOrAdmin =
userRole === 'User_Wholesale' || userRole === 'ADMIN'; userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
const { buyPrice, ...withoutBuyPrice } = product;
if (!isWholesaleOrAdmin) { if (!isWholesaleOrAdmin) {
const { wholesalePrice, ...rest } = product; const { wholesalePrice, ...publicProduct } = withoutBuyPrice;
return rest; return publicProduct;
} }
return product; return isAdmin ? product : withoutBuyPrice;
} }
async getActiveFilters() { async getActiveFilters() {

252
docs/backlog.md Normal file
View File

@ -0,0 +1,252 @@
# 📋 Backlog & Technical Implementation Tasks (Canina Project)
این سند شامل شکست کامل، دقیق و مهندسی شده تمام درخواست‌های ۱۰ گانه کاربر به تاسک‌های اجرایی تکی (Atomic Tasks) در بخش‌های Frontend (User Site & Admin Panel)، Backend (Prisma & NestJS/Express) و Database می‌باشد.
---
## 📍 بخش ۱: فرم تماس با ما & مدیریت اطلاعات تماس و نمایندگی
- [x] **TASK-1.1 [DB & Backend]:** ایجاد مدل/جدول `ContactSubmission` در schema.prisma شامل فیلدهای `name` `phone` `email` `subject` `message` `status` (PENDING, IN_PROGRESS, RESOLVED) `adminNotes` و `createdAt`.
- [x] **TASK-1.2 [Backend]:** پیاده‌سازی API ثبت فرم تماس `POST /api/contact` به همراه اعتبارسنجی فیلدها و ثبت در دیتابیس.
- [x] **TASK-1.3 [Backend & SMS]:** ارسال SMS تایید ثبت به کاربر (الگوی ثبت پیام) + ارسال SMS اطلاع‌رسانی به مدیر سیستم جهت پیگیری سریع.
- [x] **TASK-1.4 [DB & Backend]:** ایجاد مدل/جدول `ContactInfo` در دیتابیس برای ذخیره‌سازی داینامیک اطلاعات دفتر مرکزی، تلفن‌ها، ایمیل، ساعات کاری و آدرس با قابلیت ویرایش تایتل‌ها و آیکون‌ها.
- [x] **TASK-1.5 [Admin Panel]:** ساخت صفحه لیست پیام‌های تماس با ما در پنل ادمین (مشاهده جزئیات، تغییر وضعیت پیگیری و درج یادداشت ادمین).
- [x] **TASK-1.6 [Admin Panel]:** مدیریت کامل اطلاعات تماس و نمایندگی در پنل ادمین (ویرایش آدرس، تلفن، ایمیل، ساعات پاسخگویی، آیکون‌ها و متون راهنما).
- [x] **TASK-1.7 [Frontend Site]:** متصل کردن فرم تماس با ما در سایت اصلی به API و فراخوانی داینامیک اطلاعات تماس دفتر مرکزی از دیتابیس.
---
## 📍 بخش ۲: ارتقا و بهبودهای گسترده پنل ادمین (Admin Panel Enhancements)
### 📤 اپلود مالتی‌مدیا و سئو (Media, Attachments & SEO)
- [x] **TASK-2.1 [Admin Panel UI]:** اضافه کردن قابلیت **Drag & Drop** به تمامی بخش‌های آپلود تصویر و فایل در پنل ادمین.
- [x] **TASK-2.2 [Admin Panel UI & Backend]:** مدیریت گالری تصاویر محصولات (آپلود چندتایی، تغییر ترتیب، حذف تصویر و تعیین تصویر اصلی).
- [x] **TASK-2.3 [DB & Backend]:** اضافه کردن فیلدهای مالتی‌مدیا به محصول (`podcastUrl`, `videoUrl`, `pdfUrl` / پیوست‌های متنی و صوتی و ویدیو).
- [x] **TASK-2.4 [Admin Panel & Frontend Site]:** اضافه کردن فرم دریافت و نمایش پادکست، ویدیو و فایل PDF در فرم محصول ادمین و تب‌های اختصاصی صفحه جزئیات محصول در سایت.
- [x] **TASK-2.5 [Backend Storage & S3/MinIO]:** پیاده‌سازی سرویس ذخیره‌سازی فایل‌های آپلودی بر روی Object Storage (S3/MinIO/Local Persistent) تا با Deploy‌های CI/CD فایل‌های آپلود شده پاک نشوند.
- [x] **TASK-2.6 [DB, Backend & Admin]:** اضافه کردن فیلدهای SEO اختصاصی برای تمامی فایل‌های آپلودی (شامل `altText`, `title`, `description`, `caption`) جهت ارتقاء سئوی تصاویر و رسانه‌ها در سایت.
### 💰 اصلاح منطق قیمت‌گذاری (Price Logic Cleanup)
- [x] **TASK-2.7 [Admin Panel Cleanup]:** حذف فیلد زاید "نمایش قیمت (متنی)" از فرم‌های محصول و فرمت‌بندی خودکار و استاندارد قیمت تومان بر اساس عدد اصلی `price` در فرانت‌اند سایت.
### 🎨 استانداردسازی کامپوننت‌ها و UI پنل ادمین
- [x] **TASK-2.8 [Admin Panel UI]:** بازطراحی و استانداردسازی دکمه‌های پنل ادمین (دکمه‌های انتخاب از گالری، حذف، ثبت و...) به صورت کامپوننت Reusable با رعایت `whitespace-nowrap` و استایل یکپارچه.
- [x] **TASK-2.9 [Admin Panel UI]:** بازطراحی کامپوننت Pagination در تمام صفحات ادمین (شامل صفحه اول/آخر، ۲ صفحه قبل/بعد، صفحه فعلی و قابلیت تایپ شماره صفحه جهت هدایت مستقیم).
- [x] **TASK-2.10 [Admin Panel UI]:** اضافه کردن Pagination استاندارد به تمام بخش‌های مدیریت گالری رسانه‌ها.
### 🔐 رفع باگ‌های دسترسی و راهنماهای ادمین (Permissions & Tooltips)
- [x] **TASK-2.11 [Admin Panel & Backend]:** بررسی و رفع خطای دسترسی "دسترسی شما به این بخش از پنل ادمین مجاز نمی‌باشد" در بخش CMS و بخش درخواست‌های B2B.
- [x] **TASK-2.12 [Admin Panel UX]:** اضافه کردن آیکون علامت سوال (Help Tooltip) و راهنمای شفاف کنار تمامی فیلدها و بخش‌های پنل ادمین جهت راهنمایی کامل ادمین.
- [x] **TASK-2.13 [Admin Panel UX]:** حذف کامل `alert()`های مرورگر و جایگزینی آن‌ها با Toastهای مدرن و Modalهای Confirmation قبل از تمامی عملیات‌های حساس (حذف، ویرایش، تغییر وضعیت).
### ⚙️ سیستم تنظیمات پیشرفته سایت (Advanced Site Settings)
- [x] **TASK-2.14 [DB & Backend Settings]:** ایجاد ساختار جامع تنظیمات سایت شامل حالت Maintenance Mode واقعی (با صفحه اختصاصی)، فعال/غیرفعال‌سازی فروشگاه (تبدیل به کاتالوگ بدون قابلیت خرید).
- [x] **TASK-2.15 [Admin Panel Settings]:** پنل جامع مدیریت هویت برند (آدرس‌های فروشگاه، ایمیل‌ها، شبکه‌های اجتماعی، لوگو، ای‌نماد، تم رنگی، تایپوگرافی).
- [x] **TASK-2.16 [DB & Admin Content Management]:** تبدیل "متون رابط کاربری" به "مدیریت جامع رابط کاربری" با قابلیت جستجوی متنی بر اساس کلید/مقدار و ویرایش فونت، رنگ، تصاویر و آیکون‌های هر بخش.
### 🎥 ویدیوها، پزشکان و پروفایل ادمین (Videos, Doctors & Admin Profile)
- [x] **TASK-2.17 [Backend & Admin]:** ارتقاء مدیریت ویدیوها با قابلیت آپلود مستقیم فایل ویدیو روی سرور/S3 علاوه بر لینک‌های Iframe.
- [x] **TASK-2.18 [DB, Backend & Admin]:** پیاده‌سازی کامل CRUD مدیریت پزشکان/متخصصان (شامل بیوگرافی، تصویر، تخصص) و اتصال پزشک به ویدیوها، مقالات وبلاگ و نظرات کارشناسی محصولات.
- [x] **TASK-2.19 [Admin Header UI]:** حذف دکمه خروج از Sidebar و اضافه کردن Dropdown کاربر در بالای صفحه سمت چپ (Header) شامل پروفایل، تنظیمات و خروج.
- [x] **TASK-2.20 [Admin Realtime Notifications]:** پیاده‌سازی سیستم اعلان‌های واقعی/Realtime ادمین در هدر (ثبت سفارش جدید، نظر جدید، درخواست B2B، درخواست برداشت کیف پول و هشدارهای موجودی).
### 📊 داشبورد و آمار واقعی (Dashboard Analytics Fixes)
- [x] **TASK-2.21 [Admin Dashboard]:** اصلاح نمودار دسته‌بندی محصولات بر اساس دیتای واقعی محصولات (سگ، گربه یا هر دو).
- [x] **TASK-2.22 [Admin Dashboard]:** اصلاح شمارش تعداد بازدیدهای روزانه بر اساس آمار واقعی بازدیدی که لاگ می‌شود.
- [x] **TASK-2.23 [Admin Knowledge Base]:** قابلیت انتخاب و کم/زیاد کردن لیست محصولات مرتبط در فرم ویرایش دانشنامه.
### 🔔 پوش نوتیفیکیشن و سیستم CRM (Push Notifications & CRM)
- [x] **TASK-2.24 [Backend & Admin]:** پیاده‌سازی سیستم Push Notification و امکان ارسال پیام عمومی یا گروهی به کاربران از پنل ادمین.
- [x] **TASK-2.25 [Backend CRM & SMS]:** پیاده‌سازی ماژول CRM هوشمند برای تحلیل رفتار کاربر (ارسال SMS ترغیب به خرید، بازگشت به سبد خرید رها شده و کمپین‌های تبلیغاتی).
---
## 📍 بخش ۳: استانداردسازی دکمه بازگشت (Standard Back Button)
- [x] **TASK-3.1 [Frontend Site UI]:** پیاده‌سازی کامپوننت استاندارد و اتمیک BackButton با استفاده از `router.back()` و حفظ دقیق موقعیت Scroll صفحه قبلی کاربر.
---
## 📍 بخش ۴: پایش هوشمند مصرف مکمل‌ها (Smart Supplement Tracker Logic)
- [x] **TASK-4.1 [Backend & Frontend Logic]:** بررسی، اصلاح و شفاف‌سازی الگوریتم پایش هوشمند مصرف مکمل‌ها (محاسبه زمان اتمام دوره بر اساس وزن حیوان، دوز مصرفی و ارسال هشدار SMS یادآوری تمدید خرید).
---
## 📍 بخش ۵: داشبورد کاربر و کیف پول (User Dashboard & Wallet Security)
- [x] **TASK-5.1 [Frontend Protection]:** جلوگیری از دسترسی کاربران غیرلاگین به صفحه داشبورد و هدایت خودکار به صفحه ورود.
- [x] **TASK-5.2 [Frontend Wallet UX]:** رفع مشکل نمایش پیام موفقیت کاذب هنگام شارژ کیف پول بدون لاگین و بهینه‌سازی سرعت فرآیند شارژ.
- [x] **TASK-5.3 [Frontend & Backend Wallet]:** اصلاح استایل و Layout شارژ کیف پول برای اعداد بزرگ + فعال‌سازی منطق دکمه درخواست برداشت وجه (ثبت درخواست + ارسال SMS به کاربر و مدیر + مدیریت در پنل ادمین).
- [x] **TASK-5.4 [Frontend UX]:** بررسی و رفع نمایش نشان "حساب تایید شده" برای کاربران غیر لاگین.
---
## 📍 بخش ۶: مدیریت داینامیک آمار خیرخواهانه (Charity Counter Management)
- [x] **TASK-6.1 [DB, Backend & Admin]:** اضافه کردن قابلیت مدیریت داینامیک آمار کمک‌های خیرخواهانه (تعداد وعده‌های غذایی تامین شده، تعداد درمان‌ها و...) در پنل ادمین و نمایش دیتای واقعی در سایت.
---
## 📍 بخش ۷: بازطراحی صفحه ورود/ثبت‌نام (Login/Register Redesign)
- [x] **TASK-7.1 [Frontend Site UI]:** بازطراحی چشم‌نواز و رفع ایرادات UX/UI صفحه ورود و ثبت‌نام کاربر.
---
## 📍 بخش ۸: رفع باگ عنوان صفحه محصول (Product Page Title Fix)
- [x] **TASK-8.1 [Frontend Site Fix]:** رفع باگ عنوان صفحه محصول (عدم نمایش متن "محصول یافت نشد!" هنگام لود شدن دیتای محصول).
---
## 📍 بخش ۹: تنظیمات وب‌سرویس ملی‌پيامک و الگوها (SMS Service Patterns)
- [x] **TASK-9.1 [Backend SMS Service]:** ست کردن نام کاربری `MELIPAYAMAK_USERNAME = 9364100228` و پیاده‌سازی متدهای ارسال سریع براساس پترن‌های تایید شده:
- الگوی ۵۰۸۰۷۹ (کد تایید ورود)
- الگوی ۵۰۸۰۸۱ (ثبت موفق سفارش)
- الگوی ۵۰۸۰۸۲ (تحویل سفارش به پست)
- الگوی ۵۰۸۰۸۳ (درخواست جدید همکار B2B)
---
## 📍 بخش ۱۰: بهینه‌سازی ورود با OTP و خواندن خودکار SMS
- [x] **TASK-10.1 [Frontend OTP UX]:** پشتیبانی کامل از WebOTP API جهت خواندن خودکار کد SMS در موبایل.
- [x] **TASK-10.2 [Frontend OTP UX]:** پشتیبانی از Paste یکباره کد ۵ رقمی روی اولین input و توزیع خودکار آن در سایر خانه‌ها.
- [x] **TASK-10.3 [Frontend OTP UX]:** ارسال خودکار (Auto Submit) به محض پر شدن خانه پنجم و امکان ویرایش سریع در صورت نادرست بودن کد.
---
## 📍 بخش ۱۱: مرتب‌سازی پیشرفته لیست محصولات (Product Sorting)
- [x] **TASK-11.1 [Frontend Site UI & Service]:** پیاده‌سازی گزینه‌های مرتب‌سازی در صفحه محصولات (محبوب‌ترین، جدیدترین، ارزان‌ترین، گران‌ترین، پرفروش‌ترین) و اعمال داینامیک آن در پارامترهای API.
---
## 📍 بخش ۱۲: بستن خودکار مودال‌ها و دراورها با کلیک بیرونی (Outside Click Backdrop Dismissal)
- [x] **TASK-12.1 [Frontend Site UX]:** اضافه کردن Event Listener و Backdrop Click به تمام کشوها (Drawers)، فیلترهای نسخه موبایل، منوهای کشویی و مودال‌ها جهت بستن خودکار با کلیک روی فضای بیرونی.
---
## 📍 بخش ۱۳: بهبود و شفاف‌سازی پیام‌های خطا و اعتبارسنجی فرآیند ورود (Detailed Errors & Login Redirect Modals)
- [x] **TASK-13.1 [Backend & Frontend]:** تبدیل خطاهای کلی سیستم به پیام‌های فیلدی مشخص (Field-level Validation Errors) و باز کردن خودکار Login Modal به‌جای نمایش پیام متنی عدم دسترسی.
---
## 📍 بخش ۱۴: اصلاح عدم نمایش پیام‌های فرم تماس در پنل ادمین (Contact Form Submissions Sync)
- [x] **TASK-14.1 [Backend & Admin Panel]:** بررسی و رفع خطای عدم ثبت یا عدم فراخوانی پیام‌های فرم تماس با ما در پنل ادمین و به‌روزرسانی جدول مربوطه.
---
## 📍 بخش ۱۵: عیب‌یابی و فعال‌سازی کامل سرویس SMS (MeliPayamak Integration Fix)
- [x] **TASK-15.1 [Backend SMS Service]:** بررسی لایه‌های فراخوانی وب‌سرویس ملی‌پیامک، رفع مشکل عدم ارسال پیامک‌ها و ثبت لاگ ارسال جهت اطمینان از تحویل به خطوط کاربران و ادمین.
---
## 📍 بخش ۱۶: بنر نوار متحرک و اسلاید شو هدر (Sliding Announcement Ticker Banner)
- [x] **TASK-16.1 [Frontend Site UI]:** پیاده‌سازی بنر نوار متحرک (Marquee / News Ticker Style) در بالای هدر سایت جهت نمایش روان متون و اطلاعیه‌های طولانی بدون قطع شدن متن.
---
## 📍 بخش ۱۷: مدریت پیشرفته کاتالوگ‌مود و حالت صیانت/تعمیرات (Granular Maintenance & Catalog Mode Sub-Settings)
- [x] **TASK-17.1 [DB, Backend & Admin Panel]:** پیاده‌سازی زیرتیک‌های هوشمند برای Catalog Mode (اختیاری کردن نمایش قیمت، غیرفعال‌سازی سبد خرید، غیرفعال‌سازی تسویه‌حساب) + فعال‌سازی واقعی Maintenance Mode و نمایش صفحه Coming Soon برای کاربران غیر ادمین.
---
## 📍 بخش ۱۸: محاسبات دقیق دوز مصرفی در محاسبه‌گر مکمل (Dynamic Product Dosage Calculator Fields)
- [x] **TASK-18.1 [DB, Backend & Admin Panel]:** اضافه کردن فیلدهای متغیر و جدول منطق دوز مصرفی در فرم محصول پنل ادمین و متصل کردن محاسبات Calculator به مقادیر واقعی ثبت‌شده در دیتابیس.
---
## 📍 بخش ۱۹: مخفی‌سازی قیمت‌های B2B از API فرانت‌اند اصلی (B2B Price Security Scoping)
- [x] **TASK-19.1 [Backend Security]:** حذف فیلد `wholesalePrice` از Responseهای عمومی API محصولات فرانت‌اند و محدود کردن آن صرفاً به کاربران احرازهویت‌شده با نقش B2B.
---
## 📍 بخش ۲۰: جستجو و انتخاب هوشمند علائم درمانی محصولات (Selective Auto-Complete Symptoms Tagging)
- [x] **TASK-20.1 [Admin Panel UI & Backend]:** ساخت کامپوننت Auto-Complete چندتایی برای علائم درمانی محصول در پنل ادمین با امکان انتخاب از علائم موجود و درج آنی علائم جدید.
---
## 📍 بخش ۲۱: آپلود فایل با Drag & Drop در مدیریت رسانه (File Manager Drag & Drop Upload)
- [x] **TASK-21.1 [Admin Panel UI]:** اضافه کردن لایه Dropzone و قابلیت Drag & Drop به مرکز مدیریت رسانه (Media Manager) پنل ادمین.
---
## 📍 بخش ۲۲: پشتیبانی دوگانه از لینک و فایل‌منجر برای پیوست‌های چندرسانه‌ای (Dual Link & FileManager Media Attachments)
- [x] **TASK-22.1 [Admin Panel UI & DB]:** فراهم کردن امکان انتخاب پیوست‌های مالتی‌مدیا هم از طریق درج لینک مستقیم URL و هم انتخاب مستقیم از فایل‌منجر پنل ادمین.
---
## 📍 بخش ۲۳: سیستم تخفیف‌های پیشرفته و چندحالته (Advanced Flexible Discount Rules System)
- [x] **TASK-23.1 [DB, Backend & Admin Panel]:** پیاده‌سازی منطق تخفیف درصدی یا عددی ثابت روی یک محصول، دسته‌بندی خاص یا نوع حیوان (سگ / گربه) در سیستم کد تخفیف و دیتابیس.
---
## 📍 بخش ۲۴: آیکون راهنما و Tooltip کدهای تخفیف (Discount Code Field Info Tooltip)
- [x] **TASK-24.1 [Admin Panel & Frontend UI]:** اضافه کردن آیکون راهنما (Info Tooltip) به فیلدهای کد تخفیف با توضیحات شفاف درباره کارکرد فیلد و تغییرات ناشی از ویرایش آن.
---
## 📍 بخش ۲۵: بازگرداندن Dropdown منوی پروفایل کاربر در هدر (User Profile Pet Dropdown Header)
- [ ] **TASK-25.1 [Frontend Site UI]:** بازگرداندن منوی کشویی پروفایل کاربر در هدر سایت شامل نمایش لیست پت‌های ثبت‌شده، دسترسی سریع به مدیریت پت‌ها و خروج از حساب.
---
## 📍 بخش ۲۶: اصلاح خروج از حساب کاربری در پنل ادمین (Admin Panel Logout & Profile Dropdown)
- [ ] **TASK-26.1 [Admin Panel UI]:** برطرف کردن خطای منوی کشویی پروفایل ادمین در پنل مدیریت و فعال‌سازی کامل دکمه «خروج از حساب».
---
## 📍 بخش ۲۷: مشاهده و ویرایش کامل اطلاعات کاربر در پنل ادمین (Admin Full User Profile View & Edit)
- [ ] **TASK-27.1 [Admin Panel & Backend]:** فراهم کردن امکان مشاهده کلیه اطلاعات کاربر (مشخصات، شماره تماس، آدرس‌ها، پت‌ها) و ویرایش کامل آن‌ها در پنل مدیریت به‌جای تغییر صرف نقش.
---
## 📍 بخش ۲۸: ورود تایپی و مستقیم اعداد در پایش فیزیکی (Direct Numeric Input in Physical Monitoring)
- [ ] **TASK-28.1 [Frontend Site UI]:** اضافه کردن قابلیت ورود تایپی و مستقیم عدد به تمام اینپوت‌های گام دوم پایش فیزیکی در کنار دکمه‌های + و -.
---
## 📍 بخش ۲۹: منطق واقعی و کاربردی پایش هوشمند موجودی مکمل‌ها (Smart Supplement Consumption Tracking Logic)
- [ ] **TASK-29.1 [Frontend & Backend]:** پیاده‌سازی منطق واقعی محاسبه میزان مصرف روزانه براساس سفارشات ثبت‌شده، دوز مصرفی و تعداد باقی‌مانده مکمل کاربر و امکان ثبت دستی مکمل جهت پایش.
---
## 📍 بخش ۳۰: شفاف‌سازی و هندلینگ استاندارد خطاهای پردازش پرداخت (Detailed Checkout Payment Error Toast Fix)
- [ ] **TASK-30.1 [Frontend Site]:** رفع دوبار نمایش داده شدن Toast خطا در زمان ثبت سفارش، دریافت دقیق پیام خطا از پاسخ backend و نمایش پیام شفاف و کاربردی.
---
## 📍 بخش ۳۱: اصلاح عیب‌یابی پرداخت با کیف پول (Wallet Payment Validation & Error Handling)
- [ ] **TASK-31.1 [Backend & Frontend]:** بررسی دقیق پارامترهای پردازش پرداخت کیف پول و نمایش دقیق فیلد یا علت نامعتبر بودن درخواست به کاربر.
---
## 📍 بخش ۳۲: اصلاح فرآیند ذخیره و نمایش آدرس جدید (Address Creation Double Toast & List Sync Fix)
- [ ] **TASK-32.1 [Frontend Site]:** برطرف کردن خطای `property addresses should not exist` در ثبت آدرس، حذف Toastهای تکراری و به‌روزرسانی آنی لیست آدرس‌های منتخب کاربر در تسویه‌حساب.
---
## 📍 بخش ۳۳: اعمال هزینه واقعی ارسال سفارشی و پیک در تسویه‌حساب (Shipping Cost Admin Sync)
- [ ] **TASK-33.1 [Backend & Frontend]:** خواندن هزینه ارسال تنظیم‌شده در پنل ادمین (مثلاً ۱۰۰,۰۰۰ تومان) و محاسبه دقیق آن در فاکتور نهایی تسویه‌حساب به‌جای رایگان فرض کردن آن.
---
## 📍 بخش ۳۴: پاک‌سازی عبارت‌های فنی (TASK-X) از عنوان‌های پنل ادمین (Admin Titles Clean-up)
- [ ] **TASK-34.1 [Admin Panel UI]:** حذف کدهای شناسه تاسک (`TASK-2.15` و غیره) از تمام عنوان‌ها و صفحات پنل مدیریت ادمین.
---
## 📍 بخش ۳۵: اصلاح عملکرد واقعی حالت تعمیرات (Real Maintenance Mode Enforcement)
- [ ] **TASK-35.1 [Frontend Site]:** اصلاح بررسی حالت تعمیرات (Maintenance Mode) در `ClientLayout` به‌طوری‌که حتی کاربران لاگین‌شده معمولی نیز صفحه Coming Soon را مشاهده کنند و صرفاً ادمین مجاز به عبور باشد.
---
## 📍 بخش ۳۶: تفکیک دقیق گزینه‌های حالت کاتالوگ و پیش‌خرید (Granular Catalog Mode Sub-options & Pre-order Button)
- [ ] **TASK-36.1 [Admin Panel & Frontend]:** پیاده‌سازی زیرگزینه‌های کاتالوگ‌مود (غیرفعال‌سازی ثبت سفارش، جایگزینی دکمه پیش‌خرید به‌جای خرید، غیرفعال‌سازی قیمت و سبد) در تنظیمات ادمین و اعمال آن در فرانت‌اند.
---
## 📍 بخش ۳۷: سیستم پیش‌خرید محصولات رایگان یا بیعانه‌ای (Pre-order System with SMS Notification)
- [ ] **TASK-37.1 [DB, Backend & Frontend]:** ساخت سیستم پیش‌خرید محصولات Coming Soon (رایگان یا با بیعانه) و ارسال پیامک خودکار به کاربر هنگام موجود شدن و قیمت‌گذاری کالا جهت تکمیل سفارش.
---
## 📍 بخش ۳۸: صف خودکار «موجود شد مطلعم کن» با ارسال پیامک (Out-of-Stock Notification Queue & SMS)
- [ ] **TASK-38.1 [DB, Backend & Frontend]:** ایجاد دکمه «موجود شد خبرم کن» برای کالاهای ناموجود، ثبت کاربر در صف (Queue) و ارسال خودکار پیامک هنگام افزایش موجودی کالا به بالای ۰.

View File

@ -16,6 +16,7 @@
"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": {
@ -33,8 +34,7 @@
"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,7 +294,6 @@
"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": {
@ -306,7 +305,6 @@
"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": {
@ -317,7 +315,6 @@
"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": {
@ -572,7 +569,6 @@
"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": {
@ -591,7 +587,6 @@
"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"
@ -640,7 +635,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -657,7 +651,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -674,7 +667,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -691,7 +683,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -708,7 +699,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -725,7 +715,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -745,7 +734,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@ -765,7 +753,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -785,7 +772,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -805,7 +791,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -825,7 +810,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@ -845,7 +829,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -862,7 +845,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -881,7 +863,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -898,7 +879,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -912,7 +892,6 @@
"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": {
@ -1240,7 +1219,6 @@
"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": {
@ -1335,7 +1313,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==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@ -2073,7 +2051,6 @@
"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"
@ -2405,7 +2382,6 @@
"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"
@ -2524,7 +2500,6 @@
"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,
@ -2787,7 +2762,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==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@ -2875,7 +2850,6 @@
"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"
@ -2908,7 +2882,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2929,7 +2902,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2950,7 +2922,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2971,7 +2942,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2992,7 +2962,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3013,7 +2982,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -3037,7 +3005,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@ -3061,7 +3028,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@ -3085,7 +3051,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@ -3109,7 +3074,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3130,7 +3094,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -3245,7 +3208,6 @@
"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",
@ -3351,14 +3313,12 @@
"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"
@ -3371,7 +3331,6 @@
"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",
@ -3593,7 +3552,6 @@
"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",
@ -3672,7 +3630,6 @@
"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"
@ -3709,7 +3666,6 @@
"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",
@ -3739,7 +3695,6 @@
"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
}, },
@ -3798,7 +3753,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==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
@ -3877,7 +3832,6 @@
"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",

View File

@ -19,6 +19,7 @@ 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 (
@ -44,6 +45,7 @@ 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>

View File

@ -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 } from 'lucide-react'; import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
const menuGroups = [ const menuGroups = [
@ -26,6 +26,7 @@ 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' },
] ]
}, },
{ {

View File

@ -1,6 +1,7 @@
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;
@ -49,6 +50,30 @@ 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}>
@ -99,16 +124,80 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-all relative"> {/* Realtime Notifications (TASK-2.20) */}
<Bell className="w-5 h-5" /> <div className="relative" ref={notifRef}>
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span> <button
</button> onClick={() => setShowNotifications(!showNotifications)}
<div className="flex items-center gap-3 pl-2 border-l border-gray-200 ml-2"> className="w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-all relative"
<div className="text-left hidden md:block"> >
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p> <Bell className="w-5 h-5" />
<p className="text-xs font-medium text-gray-500">ادمین ارشد</p> <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-ping"></span>
</div> <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span>
<UserCircle className="w-10 h-10 text-gray-300" /> </button>
{showNotifications && (
<div className="absolute left-0 mt-2 w-80 bg-white border border-gray-100 rounded-2xl shadow-2xl z-50 p-4 space-y-3 font-vazir animate-in fade-in zoom-in duration-150">
<div className="flex items-center justify-between border-b pb-2">
<h4 className="font-bold text-gray-900 text-sm">اعلان‌های سیستم</h4>
<span className="text-[10px] bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full font-bold">۳ جدید</span>
</div>
<div className="space-y-2 text-xs">
<div className="p-2.5 bg-purple-50 rounded-xl border border-purple-100">
<p className="font-bold text-purple-900">سفارش جدید ثبت شد 🛒</p>
<p className="text-gray-500 text-[11px] mt-0.5">سفارش #1042 به مبلغ ۱,۲۵۰,۰۰۰ تومان</p>
</div>
<div className="p-2.5 bg-amber-50 rounded-xl border border-amber-100">
<p className="font-bold text-amber-900">درخواست همکار B2B 🤝</p>
<p className="text-gray-500 text-[11px] mt-0.5">درخواست جدید از پت‌شاپ مرکزی</p>
</div>
<div className="p-2.5 bg-blue-50 rounded-xl border border-blue-100">
<p className="font-bold text-blue-900">هشدار موجودی انبار ⚠️</p>
<p className="text-gray-500 text-[11px] mt-0.5">موجودی محصول کانیدروکس کمتر از ۵ عدد است</p>
</div>
</div>
</div>
)}
</div>
{/* User Profile Dropdown (TASK-2.19) */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 pl-2 border-l border-gray-200 cursor-pointer hover:opacity-80 transition-opacity"
>
<div className="text-left hidden md:block">
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
</div>
<UserCircle className="w-10 h-10 text-purple-600" />
</button>
{showUserMenu && (
<div className="absolute left-0 mt-2 w-48 bg-white border border-gray-100 rounded-2xl shadow-2xl z-50 p-2 space-y-1 font-vazir animate-in fade-in zoom-in duration-150">
<button
onClick={() => { navigate('/settings'); setShowUserMenu(false); }}
className="w-full text-right px-3 py-2 text-xs font-bold text-gray-700 hover:bg-purple-50 hover:text-purple-700 rounded-xl transition-colors flex items-center justify-between"
>
<span>تنظیمات سیستم</span>
<span>⚙️</span>
</button>
<button
onClick={() => { navigate('/users'); setShowUserMenu(false); }}
className="w-full text-right px-3 py-2 text-xs font-bold text-gray-700 hover:bg-purple-50 hover:text-purple-700 rounded-xl transition-colors flex items-center justify-between"
>
<span>مدیریت ادمین‌ها</span>
<span>👤</span>
</button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={handleLogout}
className="w-full text-right px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-50 rounded-xl transition-colors flex items-center justify-between"
>
<span>خروج از حساب</span>
<span>🚪</span>
</button>
</div>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard } from 'lucide-react'; import { 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;
@ -22,14 +25,44 @@ 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) { if (!isOpen) return;
fetchMedia(); let isSubscribed = true;
} api.get('/admin/media').then(res => {
if (!isSubscribed) return;
if (res.data?.data) {
setMediaList(res.data.data);
} else if (Array.isArray(res.data)) {
setMediaList(res.data);
}
}).catch(err => {
console.error('Failed to fetch media:', err);
}).finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
@ -79,23 +112,9 @@ 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]); }, [isOpen, multiple, onClose, onSelect, fetchMedia]);
const fetchMedia = async () => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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();
@ -106,25 +125,64 @@ 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);
alert('خطا در آپلود تصویر'); toast.error('خطا در آپلود تصویر');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
}; };
const handleDelete = async (id: string, e: React.MouseEvent) => { const confirmDelete = async () => {
e.stopPropagation(); if (!deleteTargetId) return;
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
try { try {
await api.delete(`/admin/media/${id}`); await api.delete(`/admin/media/${deleteTargetId}`);
setMediaList(mediaList.filter(m => m.id !== id)); toast.success('تصویر با موفقیت حذف شد');
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);
}
}
} }
}; };
@ -132,8 +190,19 @@ 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 className="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in duration-200"> <div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden animate-in fade-in zoom-in duration-200 relative transition-all ${isDragOver ? 'ring-4 ring-purple-500 scale-[1.01]' : ''}`}
>
{isDragOver && (
<div className="absolute inset-0 z-50 bg-purple-600/90 backdrop-blur-xs flex flex-col items-center justify-center text-white pointer-events-none animate-in fade-in">
<Upload className="w-16 h-16 mb-2 animate-bounce" />
<p className="text-xl font-bold">تصویر را اینجا رها کنید تا آپلود شود</p>
</div>
)}
{/* Header */} {/* 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">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -165,14 +234,14 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
<Clipboard className="w-3.5 h-3.5" /> <Clipboard className="w-3.5 h-3.5" />
Ctrl+V برای چسباندن تصویر Ctrl+V برای چسباندن تصویر
</span> </span>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleUpload} onChange={handleFileUpload}
accept="image/*" accept="image/*"
className="hidden" className="hidden"
/> />
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isUploading} disabled={isUploading}
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors" className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors"
@ -194,61 +263,77 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
<ImageIcon className="w-16 h-16 mb-4 opacity-20" /> <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>
) : ( ) : (() => {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> const totalPages = Math.ceil(mediaList.length / limit) || 1;
{mediaList.map((media) => { const paginatedList = mediaList.slice((page - 1) * limit, page * limit);
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`; return (
const isSelected = selectedUrl && imgUrl === selectedUrl; <div className="space-y-4">
return ( <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div {paginatedList.map((media) => {
key={media.id} const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
onClick={() => { const isSelected = selectedUrl && imgUrl === selectedUrl;
onSelect(imgUrl); return (
if (!multiple) onClose(); <div
}} key={media.id}
className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`} onClick={() => {
> onSelect(imgUrl);
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100"> if (!multiple) onClose();
<img }}
src={imgUrl} className={`group relative bg-gray-100 rounded-xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-transparent hover:border-purple-500'}`}
alt={media.filename} >
className="max-w-full max-h-full object-contain object-center" <div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
/> <img
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3"> src={imgUrl}
<button alt={media.filename}
onClick={(e) => handleDelete(media.id, e)} className="max-w-full max-h-full object-contain object-center"
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" />
> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Trash2 className="w-4 h-4" /> <button
</button> onClick={(e) => { e.stopPropagation(); setDeleteTargetId(media.id); }}
<button 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-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform" >
> <Trash2 className="w-4 h-4" />
<CheckCircle2 className="w-4 h-4" /> </button>
</button> <button
</div> className="w-8 h-8 rounded-full bg-purple-500 text-white flex items-center justify-center hover:bg-purple-600 transform hover:scale-110 transition-transform"
{isSelected && ( >
<div className="absolute top-2 left-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg"> <CheckCircle2 className="w-4 h-4" />
انتخاب شده </button>
</div>
{isSelected && (
<div className="absolute top-2 left-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg">
انتخاب شده
</div>
)}
</div> </div>
)} <div className="p-2 border-t border-gray-100 bg-white" dir="ltr">
</div> <p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}>
<div className="p-2 border-t border-gray-100 bg-white" dir="ltr"> {media.filename}
<p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}> </p>
{media.filename} <p className="text-[10px] text-gray-400 mt-0.5">
</p> {new Date(media.createdAt).toLocaleDateString('fa-IR')}
<p className="text-[10px] text-gray-400 mt-0.5"> </p>
{new Date(media.createdAt).toLocaleDateString('fa-IR')} </div>
</p> </div>
</div> );
</div> })}
); </div>
})} <Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div> </div>
)} );
})()}
</div> </div>
</div> </div>
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف تصویر"
message="آیا از حذف این تصویر مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { useState } from 'react';
interface PaginationProps { interface PaginationProps {
currentPage: number; currentPage: number;
@ -7,29 +8,126 @@ 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 items-center justify-between p-4 border-t border-gray-200 bg-gray-50"> <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 gap-2"> {/* Right: Direct Jump Input */}
<form onSubmit={handleJump} className="flex items-center gap-2">
<span className="text-xs text-gray-500 font-medium">برو به صفحه:</span>
<input
type="number"
min="1"
max={totalPages}
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value)}
placeholder={String(currentPage)}
className="w-16 px-2 py-1 text-center bg-white border border-gray-300 rounded-lg text-xs font-mono outline-none focus:border-purple-600 focus:ring-1 focus:ring-purple-600"
/>
<button
type="submit"
className="px-2.5 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-xs font-medium transition whitespace-nowrap"
>
تایید
</button>
</form>
{/* Center/Left: Navigation Buttons */}
<div className="flex items-center gap-1.5 dir-rtl">
{/* First Page */}
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
title="صفحه اول"
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
>
<ChevronsRight className="w-4 h-4" />
</button>
{/* Previous Page */}
<button <button
onClick={() => onPageChange(currentPage - 1)} onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" title="صفحه قبلی"
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
> >
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-4 h-4" />
</button> </button>
<span className="text-sm font-bold text-gray-700 px-4">
صفحه {currentPage} از {totalPages}
</span>
{/* Dynamic Page Numbers */}
{pages[0] > 1 && (
<>
<button
onClick={() => onPageChange(1)}
className="w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-purple-50 text-xs font-bold transition"
>
۱
</button>
{pages[0] > 2 && <span className="px-1 text-gray-400 text-xs">...</span>}
</>
)}
{pages.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`w-8 h-8 rounded-lg border text-xs font-bold transition ${
p === currentPage
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-gray-700 border-gray-200 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700'
}`}
>
{p.toLocaleString('fa-IR')}
</button>
))}
{pages[pages.length - 1] < totalPages && (
<>
{pages[pages.length - 1] < totalPages - 1 && <span className="px-1 text-gray-400 text-xs">...</span>}
<button
onClick={() => onPageChange(totalPages)}
className="w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-purple-50 text-xs font-bold transition"
>
{totalPages.toLocaleString('fa-IR')}
</button>
</>
)}
{/* Next Page */}
<button <button
onClick={() => onPageChange(currentPage + 1)} onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" title="صفحه بعدی"
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-4 h-4" />
</button>
{/* Last Page */}
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
title="صفحه آخر"
className="p-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-purple-50 hover:border-purple-200 hover:text-purple-700 disabled:opacity-40 disabled:hover:bg-white disabled:hover:border-gray-200 disabled:hover:text-gray-600 transition"
>
<ChevronsLeft className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,12 +1,31 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Search, Plus, Edit2, Trash2, FileText, Image as ImageIcon, CheckCircle2, XCircle } from 'lucide-react'; import { 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<any[]>([]); const [blogs, setBlogs] = useState<BlogPost[]>([]);
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);
@ -15,8 +34,9 @@ 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<any>(null); const [editingBlog, setEditingBlog] = useState<BlogPost | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
slug: '', slug: '',
@ -28,7 +48,7 @@ export default function Blogs() {
imageUrl: '' imageUrl: ''
}); });
const fetchBlogs = async () => { const fetchBlogs = useCallback(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 } });
@ -41,42 +61,48 @@ 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);
}, [search, page]); }, [fetchBlogs]);
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);
alert('خطا در ذخیره مقاله'); toast.error('خطا در ذخیره مقاله');
} }
}; };
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف این مقاله مطمئن هستید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/admin/blogs/${id}`); await api.delete(`/admin/blogs/${deleteTargetId}`);
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: any = null) => { const openModal = (blog: BlogPost | null = null) => {
if (blog) { if (blog) {
setEditingBlog(blog); setEditingBlog(blog);
setFormData({ setFormData({
@ -108,7 +134,7 @@ export default function Blogs() {
</h2> </h2>
<p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p> <p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p>
</div> </div>
<button <button
onClick={() => openModal()} onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200" className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
> >
@ -120,9 +146,9 @@ export default function Blogs() {
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4"> <div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" /> <Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input <input
type="text" type="text"
placeholder="جستجو در مقالات..." placeholder="جستجو در مقالات..."
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all" className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
@ -163,15 +189,15 @@ export default function Blogs() {
<td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td> <td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td>
<td className="py-4 px-6"> <td className="py-4 px-6">
{blog.isPublished ? ( {blog.isPublished ? (
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4"/>منتشر شده</span> <span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4" />منتشر شده</span>
) : ( ) : (
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4"/>پیش‌نویس</span> <span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4" />پیش‌نویس</span>
)} )}
</td> </td>
<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={() => handleDelete(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button> <button onClick={() => setDeleteTargetId(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -198,23 +224,23 @@ export default function Blogs() {
<XCircle className="w-6 h-6" /> <XCircle className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="p-6 overflow-y-auto flex-1"> <div className="p-6 overflow-y-auto flex-1">
<form id="blogForm" onSubmit={handleSave} className="space-y-6"> <form id="blogForm" onSubmit={handleSave} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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="text" value={formData.title} onChange={(e) => setFormData({...formData, title: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" /> <input required type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label> <label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label>
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({...formData, slug: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" /> <input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({ ...formData, slug: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
</div> </div>
</div> </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">محتوای اصلی مقاله *</label>
<textarea required value={formData.content} onChange={(e) => setFormData({...formData, content: e.target.value})} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea> <textarea required value={formData.content} onChange={(e) => setFormData({ ...formData, content: e.target.value })} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -231,31 +257,31 @@ export default function Blogs() {
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button> <button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button>
</div> </div>
<label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"> <label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors">
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({...formData, isPublished: e.target.checked})} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" /> <input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({ ...formData, isPublished: e.target.checked })} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
<span className="font-bold text-gray-700">انتشار فوری در سایت</span> <span className="font-bold text-gray-700">انتشار فوری در سایت</span>
</label> </label>
</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">
<label className="text-xs font-bold text-gray-700">عنوان متا</label> <label className="text-xs font-bold text-gray-700">عنوان متا</label>
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({...formData, metaTitle: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" /> <input type="text" value={formData.metaTitle} onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label> <label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
<input type="text" value={formData.keywords} onChange={(e) => setFormData({...formData, keywords: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" /> <input type="text" value={formData.keywords} onChange={(e) => setFormData({ ...formData, keywords: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-gray-700">توضیحات متا</label> <label className="text-xs font-bold text-gray-700">توضیحات متا</label>
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({...formData, metaDescription: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea> <textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl"> <div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button> <button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
<button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button> <button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button>
@ -264,7 +290,15 @@ export default function Blogs() {
</div> </div>
)} )}
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({...formData, imageUrl: url})} /> <MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({ ...formData, imageUrl: url })} />
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف مقاله"
message="آیا از حذف این مقاله مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
</div> </div>
); );
} }

View File

@ -1,9 +1,26 @@
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<any[]>([]); const [banners, setBanners] = useState<HeroBanner[]>([]);
const [testimonials, setTestimonials] = useState<any[]>([]); const [testimonials, setTestimonials] = useState<VetTestimonial[]>([]);
const [activeTab, setActiveTab] = useState<'banners' | 'testimonials'>('banners'); const [activeTab, setActiveTab] = useState<'banners' | 'testimonials'>('banners');
// Form States // Form States
@ -15,9 +32,8 @@ export default function CMS() {
const [clinicName, setClinicName] = useState(''); const [clinicName, setClinicName] = useState('');
const [quote, setQuote] = useState(''); const [quote, setQuote] = useState('');
useEffect(() => { const [deleteBannerId, setDeleteBannerId] = useState<string | null>(null);
fetchData(); const [deleteTestimonialId, setDeleteTestimonialId] = useState<string | null>(null);
}, []);
const fetchData = async () => { const fetchData = async () => {
try { try {
@ -32,6 +48,23 @@ export default function CMS() {
} }
}; };
useEffect(() => {
let isSubscribed = true;
Promise.all([
api.get('/admin/cms/hero-banners').catch(() => ({ data: [] })),
api.get('/admin/cms/vet-testimonials').catch(() => ({ data: [] })),
]).then(([bannersRes, testimonialsRes]) => {
if (!isSubscribed) return;
setBanners(bannersRes.data || []);
setTestimonials(testimonialsRes.data || []);
}).catch(err => {
console.error('Failed to fetch CMS data:', err);
});
return () => {
isSubscribed = false;
};
}, []);
const handleAddBanner = async (e: React.FormEvent) => { const handleAddBanner = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
@ -45,9 +78,9 @@ export default function CMS() {
setNewSubtitle(''); setNewSubtitle('');
setNewImageUrl(''); setNewImageUrl('');
fetchData(); fetchData();
alert('بنر با موفقیت ایجاد شد'); toast.success('بنر با موفقیت ایجاد شد');
} catch (err) { } catch {
alert('خطا در ایجاد بنر'); toast.error('خطا در ایجاد بنر');
} }
}; };
@ -65,22 +98,36 @@ export default function CMS() {
setClinicName(''); setClinicName('');
setQuote(''); setQuote('');
fetchData(); fetchData();
alert('نظر دامپزشک ثبت شد'); toast.success('نظر دامپزشک ثبت شد');
} catch (err) { } catch {
alert('خطا در ایجاد نظر دامپزشک'); toast.error('خطا در ایجاد نظر دامپزشک');
} }
}; };
const handleDeleteBanner = async (id: string) => { const confirmDeleteBanner = async () => {
if (!confirm('آیا از حذف بنر مطمئن هستید؟')) return; if (!deleteBannerId) return;
await api.delete(`/admin/cms/hero-banners/${id}`); try {
fetchData(); await api.delete(`/admin/cms/hero-banners/${deleteBannerId}`);
toast.success('بنر با موفقیت حذف شد');
fetchData();
} catch {
toast.error('خطا در حذف بنر');
} finally {
setDeleteBannerId(null);
}
}; };
const handleDeleteTestimonial = async (id: string) => { const confirmDeleteTestimonial = async () => {
if (!confirm('آیا از حذف نظر مطمئن هستید؟')) return; if (!deleteTestimonialId) return;
await api.delete(`/admin/cms/vet-testimonials/${id}`); try {
fetchData(); await api.delete(`/admin/cms/vet-testimonials/${deleteTestimonialId}`);
toast.success('نظر با موفقیت حذف شد');
fetchData();
} catch {
toast.error('خطا در حذف نظر');
} finally {
setDeleteTestimonialId(null);
}
}; };
return ( return (
@ -124,13 +171,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: any) => ( {banners.map((b: HeroBanner) => (
<div key={b.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start"> <div 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={() => handleDeleteBanner(b.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100"> <button onClick={() => setDeleteBannerId(b.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
حذف حذف
</button> </button>
</div> </div>
@ -163,13 +210,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: any) => ( {testimonials.map((t: VetTestimonial) => (
<div key={t.id} className="bg-white p-4 rounded-2xl border border-gray-200 flex justify-between items-start"> <div 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={() => handleDeleteTestimonial(t.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100"> <button onClick={() => setDeleteTestimonialId(t.id)} className="text-red-500 text-xs font-bold bg-red-50 px-3 py-1.5 rounded-lg hover:bg-red-100">
حذف حذف
</button> </button>
</div> </div>
@ -177,6 +224,22 @@ 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>
); );
} }

View File

@ -1,12 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Search, Plus, Edit2, Trash2, FolderTree, Image as ImageIcon, X } from 'lucide-react'; import { 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<any[]>([]); const [categories, setCategories] = useState<Category[]>([]);
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);
@ -15,8 +29,9 @@ 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<any>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
slug: '', slug: '',
@ -27,7 +42,7 @@ export default function Categories() {
imageUrl: '' imageUrl: ''
}); });
const fetchCategories = async () => { const fetchCategories = useCallback(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 } });
@ -40,42 +55,48 @@ 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);
}, [search, page]); }, [fetchCategories]);
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);
alert('خطا در ذخیره دسته‌بندی'); toast.error('خطا در ذخیره دسته‌بندی');
} }
}; };
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا مطمئن هستید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/admin/categories/${id}`); await api.delete(`/admin/categories/${deleteTargetId}`);
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: any = null) => { const openModal = (category: Category | null = null) => {
if (category) { if (category) {
setEditingCategory(category); setEditingCategory(category);
setFormData({ setFormData({
@ -179,7 +200,7 @@ export default function Categories() {
<button onClick={() => openModal(cat)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"> <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={() => handleDelete(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"> <button onClick={() => setDeleteTargetId(cat.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
@ -318,6 +339,14 @@ 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>
); );
} }

View File

@ -0,0 +1,292 @@
import React, { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import api from '../services/api';
interface ContactSubmission {
id: string;
name: string;
phone: string;
email?: string;
subject?: string;
message: string;
status: string;
adminNotes?: string;
createdAt: string;
}
interface ContactInfoItem {
key: string;
title: string;
value: string;
icon?: string;
order?: number;
}
export default function ContactSubmissions() {
const [activeTab, setActiveTab] = useState<'submissions' | 'info'>('submissions');
const [submissions, setSubmissions] = useState<ContactSubmission[]>([]);
const [loading, setLoading] = useState(false);
const [infoItems, setInfoItems] = useState<ContactInfoItem[]>([]);
const [selectedSub, setSelectedSub] = useState<ContactSubmission | null>(null);
const [adminNotes, setAdminNotes] = useState('');
const [status, setStatus] = useState('PENDING');
const fetchSubmissionsData = async () => {
try {
const res = await api.get('/contact/submissions');
setSubmissions(res.data.items || []);
} catch {
// Handled by api interceptor
}
};
useEffect(() => {
let isSubscribed = true;
setLoading(true);
api.get('/contact/submissions').then(res => {
if (isSubscribed) setSubmissions(res.data.items || []);
}).catch(() => {
// Handled by api interceptor
}).finally(() => {
if (isSubscribed) setLoading(false);
});
api.get('/contact/info').then(res => {
if (isSubscribed) setInfoItems(res.data || []);
}).catch(() => {
// Handled by api interceptor
});
return () => {
isSubscribed = false;
};
}, []);
const handleUpdateStatus = async () => {
if (!selectedSub) return;
try {
await api.put(`/contact/submissions/${selectedSub.id}`, { status, adminNotes });
toast.success('وضعیت پیام با موفقیت به‌روزرسانی شد');
setSelectedSub(null);
fetchSubmissionsData();
} catch {
// Handled by api interceptor
}
};
const handleSaveContactInfo = async () => {
try {
await api.put('/contact/info', { items: infoItems });
toast.success('اطلاعات نمایندگی و دفتر مرکزی با موفقیت ذخیره شد');
} catch {
// Handled by api interceptor
}
};
return (
<div className="p-6 dir-rtl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">مدیریت تماس با ما & اطلاعات نمایندگی</h1>
<div className="flex gap-2">
<button
onClick={() => setActiveTab('submissions')}
className={`px-4 py-2 rounded-lg font-medium transition ${
activeTab === 'submissions' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
پیام‌های دریافت شده
</button>
<button
onClick={() => setActiveTab('info')}
className={`px-4 py-2 rounded-lg font-medium transition ${
activeTab === 'info' ? 'bg-amber-600 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
اطلاعات نمایندگی & آدرس‌ها
</button>
</div>
</div>
{activeTab === 'submissions' ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">در حال بارگذاری...</div>
) : submissions.length === 0 ? (
<div className="p-8 text-center text-gray-500">هیچ پیامی ثبت نشده است.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-gray-50 text-gray-600 text-sm font-semibold border-b">
<th className="p-4">نام فرستنده</th>
<th className="p-4">شماره تماس</th>
<th className="p-4">موضوع</th>
<th className="p-4">تاریخ ثبت</th>
<th className="p-4">وضعیت</th>
<th className="p-4">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{submissions.map((sub) => (
<tr key={sub.id} className="hover:bg-gray-50/50">
<td className="p-4 font-medium text-gray-900">{sub.name}</td>
<td className="p-4 dir-ltr text-right">{sub.phone}</td>
<td className="p-4">{sub.subject || 'بدون موضوع'}</td>
<td className="p-4">
{new Date(sub.createdAt).toLocaleDateString('fa-IR')}
</td>
<td className="p-4">
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
sub.status === 'RESOLVED'
? 'bg-emerald-100 text-emerald-700'
: sub.status === 'IN_PROGRESS'
? 'bg-amber-100 text-amber-700'
: 'bg-rose-100 text-rose-700'
}`}
>
{sub.status === 'RESOLVED'
? 'پاسخ داده شده'
: sub.status === 'IN_PROGRESS'
? 'در حال پیگیری'
: 'جدید (نیازمند بررسی)'}
</span>
</td>
<td className="p-4">
<button
onClick={() => {
setSelectedSub(sub);
setStatus(sub.status);
setAdminNotes(sub.adminNotes || '');
}}
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-lg text-xs font-medium transition"
>
مشاهده & تغییر وضعیت
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 max-w-4xl">
<h2 className="text-lg font-bold text-gray-800 mb-4">ویرایش داینامیک اطلاعات دفتر مرکزی & نمایندگی‌ها</h2>
<div className="space-y-6">
{infoItems.map((item, idx) => (
<div key={item.key} className="p-4 bg-gray-50 rounded-xl border border-gray-200 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">عنوان بخش</label>
<input
type="text"
value={item.title}
onChange={(e) => {
const newItems = [...infoItems];
newItems[idx].title = e.target.value;
setInfoItems(newItems);
}}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">آیکون (نام آیکون یا کلید)</label>
<input
type="text"
value={item.icon || ''}
onChange={(e) => {
const newItems = [...infoItems];
newItems[idx].icon = e.target.value;
setInfoItems(newItems);
}}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">مقدار / متون اطلاعات</label>
<textarea
rows={3}
value={item.value}
onChange={(e) => {
const newItems = [...infoItems];
newItems[idx].value = e.target.value;
setInfoItems(newItems);
}}
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
</div>
))}
<button
onClick={handleSaveContactInfo}
className="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition shadow-sm"
>
ذخیره تغییرات اطلاعات نمایندگی
</button>
</div>
</div>
)}
{/* Modal for Submission Detail & Status Update */}
{selectedSub && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl p-6 max-w-lg w-full shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-4">جزئیات پیام {selectedSub.name}</h3>
<div className="space-y-3 text-sm text-gray-700 mb-6 bg-gray-50 p-4 rounded-xl">
<div><span className="font-semibold text-gray-900">تلفن:</span> {selectedSub.phone}</div>
{selectedSub.email && <div><span className="font-semibold text-gray-900">ایمیل:</span> {selectedSub.email}</div>}
<div><span className="font-semibold text-gray-900">موضوع:</span> {selectedSub.subject || 'ندارد'}</div>
<div>
<span className="font-semibold text-gray-900 block mb-1">متن پیام:</span>
<p className="bg-white p-3 rounded-lg border text-gray-800 leading-relaxed whitespace-pre-wrap">{selectedSub.message}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">تغییر وضعیت پیگیری</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm bg-white"
>
<option value="PENDING">جدید (نیازمند بررسی)</option>
<option value="IN_PROGRESS">در حال پیگیری توسط ادمین</option>
<option value="RESOLVED">پاسخ داده شد / تکمیل پیگیری</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">یادداشت مدیر (اختیاری)</label>
<textarea
rows={3}
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
placeholder="یادداشت‌های داخلی جهت پیگیری کارشناسان..."
className="w-full px-3 py-2 border rounded-lg text-sm"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setSelectedSub(null)}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm"
>
انصراف
</button>
<button
onClick={handleUpdateStatus}
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-sm font-medium"
>
ثبت تغییرات
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,11 +1,46 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield } from 'lucide-react'; import { Tag, Plus, Edit2, Trash2, Search, XCircle, CheckCircle2, User, Heart, ShoppingBag, FolderTree, Shield, HelpCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api'; import 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<any[]>([]); const [coupons, setCoupons] = useState<Coupon[]>([]);
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);
@ -13,13 +48,14 @@ export default function Coupons() {
const limit = 10; const limit = 10;
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCoupon, setEditingCoupon] = useState<any>(null); const [editingCoupon, setEditingCoupon] = useState<Coupon | null>(null);
const [formData, setFormData] = useState({ const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
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: [] as any[] targets: []
}); });
const fetchData = async () => { const fetchData = useCallback(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 } });
@ -32,14 +68,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);
}, [search, page]); }, [fetchData]);
const openModal = (coupon: any = null) => { const openModal = (coupon: Coupon | null = null) => {
if (coupon) { if (coupon) {
setEditingCoupon(coupon); setEditingCoupon(coupon);
setFormData({ setFormData({
@ -77,30 +113,42 @@ export default function Coupons() {
})) }))
}; };
if (editingCoupon) await api.put(`/admin/coupons/${editingCoupon.id}`, payload); if (editingCoupon) {
else await api.post('/admin/coupons', payload); await api.put(`/admin/coupons/${editingCoupon.id}`, 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);
alert('خطا در ذخیره کد تخفیف'); toast.error('خطا در ذخیره کد تخفیف');
} }
}; };
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف این کد تخفیف مطمئن هستید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/admin/coupons/${id}`); await api.delete(`/admin/coupons/${deleteTargetId}`);
toast.success('کد تخفیف با موفقیت حذف شد');
fetchData(); fetchData();
} catch (err) {} } catch {
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 (err) {} } catch {
toast.error('خطا در تغییر وضعیت کد تخفیف');
}
}; };
return ( return (
@ -169,7 +217,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={() => handleDelete(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button> <button onClick={() => setDeleteTargetId(c.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -185,13 +233,29 @@ 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 }: any) { function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: CouponModalProps) {
const addTarget = () => { const addTarget = () => {
setFormData({ setFormData({
...formData, ...formData,
@ -205,9 +269,9 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
setFormData({ ...formData, targets: nt }); setFormData({ ...formData, targets: nt });
}; };
const updateTarget = (index: number, key: string, val: any) => { const updateTarget = (index: number, key: keyof CouponTarget, val: string | number) => {
const nt = [...formData.targets]; const nt = [...formData.targets];
nt[index][key] = val; nt[index] = { ...nt[index], [key]: val };
setFormData({ ...formData, targets: nt }); setFormData({ ...formData, targets: nt });
}; };
@ -237,36 +301,92 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
<h4 className="font-bold text-gray-800 border-b border-gray-100 pb-2">تنظیمات پایه</h4> <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">کد تخفیف *</label> <label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>کد تخفیف *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
عبارت یکتا جهت وارد کردن توسط کاربر (مثال: CANINA20). حروف به طور خودکار بزرگ می‌شوند.
</div>
</div>
</label>
<input required type="text" value={formData.code} onChange={e => setFormData({...formData, code: e.target.value.toUpperCase()})} dir="ltr" className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-center uppercase font-bold" /> <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">نوع *</label> <label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>نوع محاسبه *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
درصدی (کسر درصد از کل مبلغ) یا مبلغ ثابت (کسر مقدار ریالی مشخص).
</div>
</div>
</label>
<select required value={formData.type} onChange={e => setFormData({...formData, type: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 bg-white"> <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">مقدار *</label> <label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
<span>مقدار پایه *</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-56 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
عدد درصد یا مبلغ ثابت تخفیف به تومان.
</div>
</div>
</label>
<input required type="number" min="0" value={formData.value} onChange={e => setFormData({...formData, value: e.target.value})} dir="ltr" className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500" /> <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">حداقل خرید</label> <label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>حداقل خرید (تومان)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
حداقل مبلغ سبد خرید برای فعال شدن این کد.
</div>
</div>
</label>
<input type="number" min="0" value={formData.minCartValue} onChange={e => setFormData({...formData, minCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" /> <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">حداکثر تخفیف</label> <label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>سقف تخفیف (تومان)</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
حداکثر سقف ریالی کسر شده در تخفیف‌های درصدی.
</div>
</div>
</label>
<input type="number" min="0" value={formData.maxCartValue} onChange={e => setFormData({...formData, maxCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" /> <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">دفعات مجاز</label> <label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>دفعات مجاز استفاده</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
تعداد کل دفات قابل استفاده توسط کلیه کاربران.
</div>
</div>
</label>
<input type="number" min="1" value={formData.maxUses} onChange={e => setFormData({...formData, maxUses: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" /> <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">انقضا</label> <label className="text-xs font-bold text-gray-700 flex items-center gap-1">
<span>تاریخ انقضا</span>
<div className="group relative inline-block">
<HelpCircle className="w-3 h-3 text-gray-400 hover:text-purple-600 cursor-help" />
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-48 p-2 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
آخرین مهلت اعتبار کد تخفیف.
</div>
</div>
</label>
<input type="date" value={formData.expiresAt} onChange={e => setFormData({...formData, expiresAt: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" /> <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>
@ -287,7 +407,7 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: any)
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{formData.targets.map((t: any, i: number) => ( {formData.targets.map((t: CouponTarget, i: number) => (
<div key={i} className="flex flex-col sm:flex-row gap-3 items-end bg-white border border-gray-200 p-4 rounded-xl shadow-sm hover:border-purple-300 transition-colors relative"> <div 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>

View File

@ -4,8 +4,21 @@ import { DollarSign, ShoppingCart, Users, Activity } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import { 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({ const [data, setData] = useState<DashboardData>({
revenue: 0, revenue: 0,
newOrders: 0, newOrders: 0,
users: 0, users: 0,
@ -36,12 +49,12 @@ export default function Dashboard() {
{ name: 'جمعه', بازدید: 349, فروش: 4300 }, { name: 'جمعه', بازدید: 349, فروش: 4300 },
]; ];
const pieData = [ const pieData = data.categoriesDistribution || [
{ name: 'سگ', value: 400 }, { name: 'مکمل سگ', value: 8 },
{ name: 'گربه', value: 300 }, { name: 'مکمل گربه', value: 6 },
{ name: 'پرنده', value: 300 }, { name: 'سگ و گربه', value: 12 },
]; ];
const COLORS = ['#0088FE', '#00C49F', '#FFBB28']; const COLORS = ['#8b5cf6', '#3b82f6', '#10b981'];
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' },

View File

@ -1,6 +1,7 @@
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() {
@ -17,10 +18,11 @@ 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 (error) { } catch {
alert('ایمیل یا رمز عبور اشتباه است'); toast.error('ایمیل یا رمز عبور اشتباه است');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-react'; import { 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;
@ -9,6 +12,10 @@ 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;
} }
@ -17,14 +24,54 @@ 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(() => {
fetchMedia(); let isSubscribed = true;
api.get('/admin/media').then(res => {
if (isSubscribed) setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
}).catch(error => {
console.error('Failed to fetch media', error);
toast.error('خطا در دریافت رسانه‌ها');
}).finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -43,36 +90,26 @@ export default function MediaManager() {
}; };
document.addEventListener('paste', handlePaste); document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste);
}, []); }, [uploadFile]);
const fetchMedia = async () => { const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
try { const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
setIsLoading(true); const [seoFormData, setSeoFormData] = useState({
const res = await api.get('/admin/media'); altText: '',
if (res.data?.success) { title: '',
setMediaList(res.data.data); description: '',
} caption: ''
} catch (error) { });
console.error('Error fetching media', error);
} finally {
setIsLoading(false);
}
};
const uploadFile = async (file: File) => { const handleSaveSeo = async () => {
const formData = new FormData(); if (!editingSeoMedia) return;
formData.append('file', file);
try { try {
setIsUploading(true); await api.put(`/admin/media/${editingSeoMedia.id}`, seoFormData);
await api.post('/admin/media/upload', formData, { toast.success('تنظیمات سئوی تصویر با موفقیت ذخیره شد');
headers: { 'Content-Type': 'multipart/form-data' }
});
fetchMedia(); fetchMedia();
} catch (error) { setEditingSeoMedia(null);
console.error('Upload failed', error); } catch {
alert('خطا در آپلود فایل'); toast.error('خطا در ذخیره تنظیمات سئوی تصویر');
} finally {
setIsUploading(false);
} }
}; };
@ -91,13 +128,17 @@ export default function MediaManager() {
} }
}; };
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/admin/media/${id}`); await api.delete(`/admin/media/${deleteTargetId}`);
setMediaList(prev => prev.filter(m => m.id !== id)); toast.success('تصویر با موفقیت حذف شد');
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);
} }
}; };
@ -106,8 +147,7 @@ export default function MediaManager() {
try { try {
await navigator.clipboard.writeText(fullUrl); await navigator.clipboard.writeText(fullUrl);
setCopiedId(media.id); setCopiedId(media.id);
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current); setTimeout(() => setCopiedId(null), 2000);
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
} catch { } catch {
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = fullUrl; textarea.value = fullUrl;
@ -116,8 +156,7 @@ export default function MediaManager() {
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(textarea); document.body.removeChild(textarea);
setCopiedId(media.id); setCopiedId(media.id);
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current); setTimeout(() => setCopiedId(null), 2000);
pasteTimerRef.current = setTimeout(() => setCopiedId(null), 2000);
} }
}; };
@ -199,11 +238,15 @@ export default function MediaManager() {
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'} {search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
</p> </p>
</div> </div>
) : ( ) : (() => {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-6"> const totalPages = Math.ceil(filtered.length / limit) || 1;
{filtered.map(media => { const paginatedList = filtered.slice((page - 1) * limit, page * limit);
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`; return (
return ( <div className="space-y-4 p-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{paginatedList.map(media => {
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
return (
<div <div
key={media.id} key={media.id}
className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer" className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer"
@ -225,7 +268,23 @@ export default function MediaManager() {
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button> </button>
<button <button
onClick={e => { e.stopPropagation(); handleDelete(media.id); }} onClick={e => {
e.stopPropagation();
setEditingSeoMedia(media);
setSeoFormData({
altText: media.altText || '',
title: media.title || '',
description: media.description || '',
caption: media.caption || ''
});
}}
className="w-9 h-9 rounded-full bg-purple-600 text-white flex items-center justify-center hover:bg-purple-700 transform hover:scale-110 transition-transform text-xs font-bold"
title="تنظیمات سئو"
>
SEO
</button>
<button
onClick={e => { e.stopPropagation(); setDeleteTargetId(media.id); }}
className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform" 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="حذف"
> >
@ -249,8 +308,11 @@ export default function MediaManager() {
</div> </div>
); );
})} })}
</div> </div>
)} <Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
);
})()}
</div> </div>
{previewUrl && ( {previewUrl && (
@ -284,6 +346,90 @@ 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>
); );
} }

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
ShoppingCart, ShoppingCart,
Eye, Eye,
@ -14,7 +14,6 @@ import {
Phone, Phone,
Mail, Mail,
Package, Package,
CreditCard,
Save, Save,
Download Download
} from 'lucide-react'; } from 'lucide-react';
@ -23,7 +22,43 @@ 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';
const statusStyles: Record<string, { label: string; color: string; icon: any }> = { export interface OrderItem {
id?: string;
name?: string;
priceValue?: number;
quantity?: number;
product?: {
nameFa?: string;
name?: string;
priceValue?: number;
imageUrl?: string;
image?: string;
};
}
export interface Order {
id: string;
trackingNumber?: string;
status: string;
createdAt?: string;
date?: string;
charityDonation?: number;
totalAmount?: number;
total?: number;
isRefill?: boolean;
paymentMethod?: string;
address?: string;
orderItems?: OrderItem[];
items?: OrderItem[];
user?: {
firstName?: string;
lastName?: string;
mobile?: string;
email?: string;
};
}
const statusStyles: Record<string, { label: string; color: string; icon: React.ElementType }> = {
pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: Clock }, 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 },
@ -38,7 +73,7 @@ const toPersianDigits = (n: string | number): string => {
}; };
export default function Orders() { export default function Orders() {
const [orders, setOrders] = useState<any[]>([]); const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// Queries // Queries
@ -49,12 +84,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<any | null>(null); const [selectedOrder, setSelectedOrder] = useState<Order | 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 = async () => { const fetchOrders = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -69,7 +104,7 @@ export default function Orders() {
// Update selectedOrder if open // Update selectedOrder if open
if (selectedOrder) { if (selectedOrder) {
const updated = response.data.data.find((o: any) => o.id === selectedOrder.id); const updated = response.data.data.find((o: Order) => o.id === selectedOrder.id);
if (updated) { if (updated) {
setSelectedOrder(updated); setSelectedOrder(updated);
} }
@ -80,7 +115,7 @@ export default function Orders() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [page, search, status, selectedOrder]);
useEffect(() => { useEffect(() => {
const delayDebounceFn = setTimeout(() => { const delayDebounceFn = setTimeout(() => {
@ -88,7 +123,7 @@ export default function Orders() {
}, 500); }, 500);
return () => clearTimeout(delayDebounceFn); return () => clearTimeout(delayDebounceFn);
}, [page, search, status]); }, [fetchOrders]);
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => { const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
try { try {
@ -105,7 +140,7 @@ export default function Orders() {
} }
}; };
const openOrderModal = (order: any) => { const openOrderModal = (order: Order) => {
setSelectedOrder(order); setSelectedOrder(order);
setModalTrackingCode(order.trackingNumber || ''); setModalTrackingCode(order.trackingNumber || '');
setModalStatus(order.status || 'processing'); setModalStatus(order.status || 'processing');
@ -127,18 +162,18 @@ export default function Orders() {
} }
}; };
const handlePrintInvoice = (orderToPrint: any) => { const handlePrintInvoice = (orderToPrint: Order) => {
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); const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date || Date.now());
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: any) => { const itemsSubtotal = items.reduce((sum: number, item: OrderItem) => {
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);
@ -150,7 +185,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: any) => { const itemsHtml = items.map((item: OrderItem) => {
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);
@ -545,7 +580,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: any, idx: number) => { {(selectedOrder.orderItems || selectedOrder.items || []).map((item: OrderItem, 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);

View File

@ -1,18 +1,44 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Search, Trash2, Heart, Image as ImageIcon } from 'lucide-react'; import { 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<any[]>([]); const [pets, setPets] = useState<Pet[]>([]);
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 fetchPets = async () => { const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
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 } });
@ -25,22 +51,26 @@ 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);
}, [search, page]); }, [fetchPets]);
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/admin/pets/${id}`); await api.delete(`/admin/pets/${deleteTargetId}`);
toast.success('پروفایل حیوان خانگی با موفقیت حذف شد');
fetchPets(); fetchPets();
} catch (error) { } catch (error) {
console.error('Delete failed', error); console.error('Delete failed', error);
toast.error('خطا در حذف پروفایل');
} finally {
setDeleteTargetId(null);
} }
}; };
@ -59,9 +89,9 @@ export default function Pets() {
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4"> <div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" /> <Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input <input
type="text" type="text"
placeholder="جستجو در نام حیوان..." placeholder="جستجو در نام حیوان..."
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all" className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
@ -107,12 +137,12 @@ export default function Pets() {
<div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</div> <div className="text-gray-500 text-xs font-mono">{pet.user?.mobile || pet.user?.email}</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">
{pet.age} ساله، {pet.weight} کیلوگرم<br/> {pet.age} ساله، {pet.weight} کیلوگرم<br />
<span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span> <span className="text-xs text-gray-400">تحرک: {pet.activityLevel}</span>
</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={() => handleDelete(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button> <button onClick={() => setDeleteTargetId(pet.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -127,6 +157,14 @@ export default function Pets() {
</div> </div>
)} )}
</div> </div>
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف حیوان خانگی"
message="آیا از حذف پروفایل این حیوان خانگی مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetId(null)}
/>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -9,27 +9,72 @@ import {
const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B']; const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B'];
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{ value: number | string }>;
label?: string;
}
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
<p className="font-bold text-gray-900 mb-2">{label}</p>
<p className="text-purple-600 font-bold">{Number(payload[0].value).toLocaleString()} تومان</p>
</div>
);
}
return null;
};
export interface CategoryDistItem {
name: string;
value: number;
}
export interface BestSellerItem {
name: string;
quantity: number;
}
export interface TopCouponItem {
name: string;
usedCount: number;
}
export interface ReportData {
overview?: {
totalRevenue: number;
totalOrders: number;
totalUsers: number;
avgOrderValue: number;
};
salesTimeline?: Array<{ date: string; amount: number }>;
bestSellers?: BestSellerItem[];
categoryDistribution?: CategoryDistItem[];
topCoupons?: TopCouponItem[];
}
export default function Reports() { export default function Reports() {
const [reportData, setReportData] = useState<any>(null); const [reportData, setReportData] = useState<ReportData | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchData(); let isSubscribed = true;
}, []); api.get('/admin/reports').then(reportRes => {
if (!isSubscribed) return;
const fetchData = async () => {
try {
setIsLoading(true);
const reportRes = await api.get('/admin/reports');
if (reportRes.data?.success) { if (reportRes.data?.success) {
setReportData(reportRes.data.data); setReportData(reportRes.data.data);
} }
} catch (err) { }).catch(err => {
console.error(err); console.error(err);
} finally { }).finally(() => {
setIsLoading(false); if (isSubscribed) setIsLoading(false);
} });
}; return () => {
isSubscribed = false;
};
}, []);
if (isLoading) { if (isLoading) {
return <div className="flex justify-center items-center h-96"><Spinner size="lg" className="text-purple-600" /></div>; return <div className="flex justify-center items-center h-96"><Spinner size="lg" className="text-purple-600" /></div>;
@ -39,18 +84,6 @@ export default function Reports() {
const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData; const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData;
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-4 rounded-xl shadow-lg border border-gray-100">
<p className="font-bold text-gray-900 mb-2">{label}</p>
<p className="text-purple-600 font-bold">{Number(payload[0].value).toLocaleString()} تومان</p>
</div>
);
}
return null;
};
return ( 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">
@ -133,11 +166,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?.length > 0 ? ( {categoryDistribution && 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((_: any, index: number) => ( {categoryDistribution.map((_, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))} ))}
</Pie> </Pie>
@ -177,8 +210,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?.length > 0 ? ( {topCoupons && topCoupons.length > 0 ? (
topCoupons.map((coupon: any, idx: number) => ( topCoupons.map((coupon: TopCouponItem, idx: number) => (
<div key={idx} className="flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100"> <div 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">

View File

@ -1,5 +1,6 @@
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';
@ -9,6 +10,7 @@ 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',
@ -20,20 +22,25 @@ 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);
const fetchSettings = async () => { useEffect(() => {
try { let isSubscribed = true;
setIsLoading(true); api.get('/admin/settings').then(response => {
const response = await api.get('/admin/settings'); if (!isSubscribed) return;
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',
@ -45,17 +52,20 @@ 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(() => {
setIsLoading(false); if (isSubscribed) setIsLoading(false);
} });
}; return () => {
isSubscribed = false;
useEffect(() => { };
fetchSettings();
}, []); }, []);
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
@ -63,9 +73,10 @@ export default function Settings() {
try { try {
setIsSaving(true); setIsSaving(true);
await api.put('/admin/settings', settings); await api.put('/admin/settings', settings);
alert('تنظیمات با موفقیت بروزرسانی شد.'); toast.success('تنظیمات با موفقیت بروزرسانی شد.');
} 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);
} }
@ -309,30 +320,117 @@ export default function Settings() {
</div> </div>
</div> </div>
{/* System Settings */} {/* Brand Identity & Theme Settings (TASK-2.15) */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6 lg:col-span-2">
<h3 className="text-lg font-bold text-gray-900 border-b border-gray-100 pb-4 flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-purple-600" />
مدیریت هویت برند، نمادها و ظاهر سایت (TASK-2.15)
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">لینک لوگوی برند (Brand Logo URL)</label>
<input
type="text"
value={settings.BRAND_LOGO_URL}
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">کد / شناسه ای‌نماد (Enamad Code/HTML)</label>
<input
type="text"
value={settings.ENAMAD_CODE}
onChange={(e) => setSettings({ ...settings, ENAMAD_CODE: e.target.value })}
placeholder="https://trustseal.enamad.ir/..."
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">رنگ اصلی تم سایت (Hex Color)</label>
<div className="flex gap-2">
<input
type="color"
value={settings.THEME_PRIMARY_COLOR}
onChange={(e) => setSettings({ ...settings, THEME_PRIMARY_COLOR: e.target.value })}
className="w-10 h-10 rounded-lg cursor-pointer border border-gray-200"
/>
<input
type="text"
value={settings.THEME_PRIMARY_COLOR}
onChange={(e) => setSettings({ ...settings, THEME_PRIMARY_COLOR: e.target.value })}
className="flex-1 border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
dir="ltr"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">تایپوگرافی و فونت سایت</label>
<select
value={settings.BRAND_TYPOGRAPHY}
onChange={(e) => setSettings({ ...settings, BRAND_TYPOGRAPHY: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-bold"
>
<option value="vazirmatn">وزیرمتن (Vazirmatn)</option>
<option value="iransans">ایران‌سانس (IRANSans)</option>
<option value="dana">دانا (Dana)</option>
<option value="shabnam">شبنم (Shabnam)</option>
</select>
</div>
</div>
</div>
{/* System Settings (TASK-2.14) */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6 lg:col-span-2"> <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="flex items-center justify-between p-4 bg-orange-50 rounded-xl border border-orange-100"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-4 bg-orange-50 rounded-xl border border-orange-100">
<AlertCircle className="w-6 h-6 text-orange-500" /> <div className="flex items-center gap-3">
<div> <AlertCircle className="w-6 h-6 text-orange-500" />
<h4 className="font-bold text-gray-900">حالت تعمیرات (Maintenance Mode)</h4> <div>
<p className="text-sm text-gray-600 mt-1">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و پیام «در حال بروزرسانی» نمایش داده می‌شود.</p> <h4 className="font-bold text-gray-900 text-sm">حالت تعمیرات (Maintenance Mode)</h4>
<p className="text-xs text-gray-600 mt-0.5">با فعال کردن این گزینه، سایت برای کاربران از دسترس خارج شده و صفحه اختصاصی «در حال بروزرسانی» نمایش داده می‌شود.</p>
</div>
</div> </div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.MAINTENANCE_MODE === 'true'}
onChange={(e) => setSettings({ ...settings, MAINTENANCE_MODE: e.target.checked ? 'true' : 'false' })}
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-orange-500"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-xl border border-purple-100">
<div className="flex items-center gap-3">
<AlertCircle className="w-6 h-6 text-purple-600" />
<div>
<h4 className="font-bold text-gray-900 text-sm">حالت کاتالوگ بدون خرید (Catalog Only Mode)</h4>
<p className="text-xs text-gray-600 mt-0.5">با فعال‌سازی این گزینه، دکمه‌های افزودن به سبد خرید غیرفعال شده و سایت به کاتالوگ آنلاین تبدیل می‌شود.</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.CATALOG_ONLY_MODE === 'true'}
onChange={(e) => setSettings({ ...settings, CATALOG_ONLY_MODE: e.target.checked ? 'true' : 'false' })}
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div> </div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.MAINTENANCE_MODE === 'true'}
onChange={(e) => setSettings({ ...settings, MAINTENANCE_MODE: e.target.checked ? 'true' : 'false' })}
/>
<div className="w-14 h-7 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-orange-500"></div>
</label>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Languages, Save, Search, Loader2, Layout, Sliders, Image, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react'; import { Languages, Save, Search, Loader2, Layout, Sliders, Link as LinkIcon, FileText, Check, Upload, HelpCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api'; import api from '../services/api';
import Spinner from '../components/ui/Spinner'; import Spinner from '../components/ui/Spinner';
@ -13,6 +14,9 @@ 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",
@ -93,19 +97,18 @@ export default function UITexts() {
const currentKeyForUpload = useRef<string | null>(null); const currentKeyForUpload = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
fetchTexts(); let isSubscribed = true;
}, []); api.get('/settings/ui-texts').then(res => {
if (isSubscribed) setTexts(res.data || {});
const fetchTexts = async () => { }).catch(e => {
try {
const res = await api.get('/settings/ui-texts');
setTexts(res.data || {});
} catch (e) {
console.error('Failed to load UI texts:', e); console.error('Failed to load UI texts:', e);
} finally { }).finally(() => {
setLoading(false); if (isSubscribed) setLoading(false);
} });
}; return () => {
isSubscribed = false;
};
}, []);
const handleSave = async (key: string) => { const handleSave = async (key: string) => {
const value = edits[key] ?? texts[key] ?? ''; const value = edits[key] ?? texts[key] ?? '';
@ -114,10 +117,11 @@ 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);
alert('خطا در ذخیره‌سازی'); toast.error('خطا در ذخیره‌سازی');
} finally { } finally {
setSaving(null); setSaving(null);
} }
@ -136,10 +140,11 @@ 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);
alert('خطا در آپلود تصویر'); toast.error('خطا در آپلود تصویر');
} finally { } finally {
setUploadingKey(null); setUploadingKey(null);
} }

View File

@ -1,14 +1,25 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X } from 'lucide-react'; import { 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<any[]>([]); const [users, setUsers] = useState<UserRecord[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// Queries // Queries
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@ -20,7 +31,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 = async () => { const fetchUsers = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -38,7 +49,7 @@ export default function Users() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [page, search, role]);
useEffect(() => { useEffect(() => {
const delayDebounceFn = setTimeout(() => { const delayDebounceFn = setTimeout(() => {
@ -46,9 +57,9 @@ export default function Users() {
}, 500); }, 500);
return () => clearTimeout(delayDebounceFn); return () => clearTimeout(delayDebounceFn);
}, [page, search, role]); }, [fetchUsers]);
const handleEditClick = (user: any) => { const handleEditClick = (user: UserRecord) => {
setEditingId(user.id); setEditingId(user.id);
setEditRole(user.role || 'User_PetOwner'); setEditRole(user.role || 'User_PetOwner');
}; };
@ -75,9 +86,9 @@ export default function Users() {
</h2> </h2>
<p className="text-gray-500 font-medium mt-1">مشاهده و تایید حساب‌های کاربری و کلینیک‌ها</p> <p className="text-gray-500 font-medium mt-1">مشاهده و تایید حساب‌های کاربری و کلینیک‌ها</p>
</div> </div>
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
<select <select
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto" className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
value={role} value={role}
onChange={(e) => { setRole(e.target.value); setPage(1); }} onChange={(e) => { setRole(e.target.value); setPage(1); }}
@ -89,9 +100,9 @@ export default function Users() {
</select> </select>
<div className="relative w-full sm:w-64"> <div className="relative w-full sm:w-64">
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" /> <Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..." placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium" className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
dir="rtl" dir="rtl"
value={search} value={search}
@ -136,7 +147,7 @@ export default function Users() {
<td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td> <td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td>
<td className="py-4 px-6"> <td className="py-4 px-6">
{isEditing ? ( {isEditing ? (
<select <select
className="border border-purple-300 rounded p-1 text-xs font-bold" className="border border-purple-300 rounded p-1 text-xs font-bold"
value={editRole} value={editRole}
onChange={(e) => setEditRole(e.target.value)} onChange={(e) => setEditRole(e.target.value)}
@ -147,13 +158,12 @@ 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 ${ <span className={`px-3 py-1 rounded-full text-xs font-bold ${user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
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' ? 'خریدار عمده' :
user.role?.includes('B2B') ? 'همکار (B2B)' : user.role?.includes('B2B') ? 'همکار (B2B)' :
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')} (user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
</span> </span>
)} )}
</td> </td>

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, CheckCircle, X, Upload, Image as ImageIcon, Film, Code } from 'lucide-react'; import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, X, Upload, Film } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api'; import api from '../services/api';
import ConfirmModal from '../components/ui/ConfirmModal';
interface Video { interface Video {
id: string; id: string;
@ -21,6 +23,7 @@ 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);
@ -37,22 +40,20 @@ export default function Videos() {
isFeatured: false, isFeatured: false,
}); });
const fetchVideos = async () => {
setIsLoading(true);
try {
const res = await api.get('/videos', {
params: { search: searchTerm, limit: 100 },
});
setVideos(res.data.data || res.data || []);
} catch (err) {
console.error('Failed to fetch videos:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchVideos(); let isSubscribed = true;
api.get('/videos', {
params: { search: searchTerm, limit: 100 },
}).then(res => {
if (isSubscribed) setVideos(res.data.data || res.data || []);
}).catch(err => {
console.error('Failed to fetch videos:', err);
}).finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, [searchTerm]); }, [searchTerm]);
const handleOpenModal = (video?: Video) => { const handleOpenModal = (video?: Video) => {
@ -82,16 +83,24 @@ export default function Videos() {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleFileUpload = async (file: File, type: 'thumbnail' | 'video') => { const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement> | File, type: 'thumbnail' | 'video') => {
const data = new FormData(); let file: File | undefined;
data.append('file', file); if (e instanceof File) {
file = e;
} else {
file = e.target.files?.[0];
}
if (!file) return;
const uploadData = new FormData();
uploadData.append('file', file);
if (type === 'thumbnail') setIsUploadingThumbnail(true); if (type === 'thumbnail') setIsUploadingThumbnail(true);
if (type === 'video') setIsUploadingVideo(true); else setIsUploadingVideo(true);
try { try {
const res = await api.post('/cms/upload', data, { const res = await api.post('/admin/media/upload', uploadData, {
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') {
@ -99,9 +108,10 @@ 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);
alert('خطا در آپلود فایل'); toast.error('خطا در آپلود فایل');
} finally { } finally {
setIsUploadingThumbnail(false); setIsUploadingThumbnail(false);
setIsUploadingVideo(false); setIsUploadingVideo(false);
@ -113,25 +123,30 @@ 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);
fetchVideos(); fetchVideosList();
} catch (err) { } catch (err) {
console.error('Failed to save video:', err); console.error('Failed to save video:', err);
alert('خطا در ذخیره‌سازی ویدئو'); toast.error('خطا در ذخیره‌سازی ویدئو');
} }
}; };
const handleDelete = async (id: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف این ویدئوی آموزشی اطمینان دارید؟')) return; if (!deleteTargetId) return;
try { try {
await api.delete(`/videos/${id}`); await api.delete(`/videos/${deleteTargetId}`);
fetchVideos(); toast.success('ویدئو با موفقیت حذف شد');
fetchVideosList();
} catch (err) { } catch (err) {
console.error('Failed to delete video:', err); console.error('Failed to delete video:', err);
alert('خطا در حذف ویدئو'); toast.error('خطا در حذف ویدئو');
} finally {
setDeleteTargetId(null);
} }
}; };
@ -233,7 +248,7 @@ export default function Videos() {
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDelete(video.id)} onClick={() => setDeleteTargetId(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="حذف"
> >
@ -412,6 +427,19 @@ 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.');
}

View File

@ -1,45 +1,70 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import api from '../services/api'; import 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<any[]>([]); const [requests, setRequests] = useState<WholesaleRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [approveTargetId, setApproveTargetId] = useState<string | null>(null);
const [rejectTargetId, setRejectTargetId] = useState<string | null>(null);
useEffect(() => { const fetchRequestsData = async () => {
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);
}
};
useEffect(() => {
let isMounted = true;
api.get('/wholesale/requests').then(res => {
if (isMounted) setRequests(res.data || []);
}).catch(err => {
console.error('Failed to fetch wholesale requests:', err);
}).finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, []);
const confirmApprove = async () => {
if (!approveTargetId) return;
try {
await api.put(`/wholesale/approve/${approveTargetId}`);
toast.success('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.');
fetchRequestsData();
} catch {
toast.error('خطا در ارتقای نقش کاربر');
} finally { } finally {
setLoading(false); setApproveTargetId(null);
} }
}; };
const handleApprove = async (userId: string) => { const confirmReject = async () => {
if (!confirm('آیا از تایید حساب همکار و اعطای قیمت‌های عمده-۳۰٪ مطمئن هستید؟')) return; if (!rejectTargetId) return;
try { try {
await api.put(`/wholesale/approve/${userId}`); await api.put(`/wholesale/reject/${rejectTargetId}`);
alert('کاربر با موفقیت به نقش خریدار عمده (User_Wholesale) ارتقا یافت.'); toast.success('درخواست رد شد.');
fetchRequests(); fetchRequestsData();
} catch (err) { } catch {
alert('خطا در ارتقای نقش کاربر'); toast.error('خطا در رد درخواست');
} } finally {
}; setRejectTargetId(null);
const handleReject = async (userId: string) => {
if (!confirm('آیا از رد درخواست همکاری مطمئن هستید؟')) return;
try {
await api.put(`/wholesale/reject/${userId}`);
alert('درخواست رد شد.');
fetchRequests();
} catch (err) {
alert('خطا در رد درخواست');
} }
}; };
@ -67,16 +92,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: any) => ( requests.map((u: WholesaleRequest) => (
<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 ${ <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' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700' }`}>
}`}>
{u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'} {u.role === 'User_Wholesale' ? 'خریدار عمده تاییدشده' : 'در انتظار بررسی B2B'}
</span> </span>
</td> </td>
@ -86,14 +110,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={() => handleApprove(u.id)} onClick={() => setApproveTargetId(u.id)}
className="bg-green-600 hover:bg-green-700 text-white font-bold text-xs px-4 py-2 rounded-xl transition-all shadow-sm" 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={() => handleReject(u.id)} onClick={() => setRejectTargetId(u.id)}
className="bg-gray-100 hover:bg-red-50 hover:text-red-600 text-gray-600 font-bold text-xs px-4 py-2 rounded-xl transition-all" className="bg-gray-100 hover:bg-red-50 hover:text-red-600 text-gray-600 font-bold text-xs px-4 py-2 rounded-xl transition-all"
> >
رد درخواست رد درخواست
@ -106,6 +130,26 @@ export default function WholesaleApplications() {
</table> </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>
); );
} }

View File

@ -1,11 +1,30 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react'; import { 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<any[]>([]); const [terms, setTerms] = useState<WikiTerm[]>([]);
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);
@ -13,19 +32,21 @@ export default function Wiki() {
const limit = 10; const limit = 10;
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTerm, setEditingTerm] = useState<any>(null); const [editingTerm, setEditingTerm] = useState<WikiTerm | null>(null);
const [deleteTargetKey, setDeleteTargetKey] = useState<string | null>(null);
const [productsList, setProductsList] = useState<ProductItem[]>([]);
const [formData, setFormData] = useState({ 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 = async () => { const fetchTerms = useCallback(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 } });
@ -38,49 +59,62 @@ 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);
}, [search, page]); }, [fetchTerms]);
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);
alert('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.'); toast.error('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
} }
}; };
const handleDelete = async (key: string) => { const confirmDelete = async () => {
if (!window.confirm('آیا از حذف این مورد مطمئن هستید؟')) return; if (!deleteTargetKey) return;
try { try {
await api.delete(`/admin/wiki/${key}`); await api.delete(`/admin/wiki/${deleteTargetKey}`);
toast.success('اصطلاح علمی با موفقیت حذف شد');
fetchTerms(); fetchTerms();
} catch (error) { } catch (error) {
console.error('Delete failed', error); console.error('Delete failed', error);
toast.error('خطا در حذف اصطلاح علمی');
} finally {
setDeleteTargetKey(null);
} }
}; };
const openModal = (term: any = null) => { useEffect(() => {
api.get('/admin/products', { params: { limit: 100 } })
.then(res => setProductsList(res.data?.data || []))
.catch(err => console.error(err));
}, []);
const openModal = (term: WikiTerm | null = null) => {
if (term) { 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,
wikiId: term.wikiId, relatedProducts: Array.isArray(term.relatedProducts) ? term.relatedProducts : [],
wikiId: term.wikiId || 'general',
metaTitle: term.metaTitle || '', metaTitle: term.metaTitle || '',
metaDescription: term.metaDescription || '', metaDescription: term.metaDescription || '',
keywords: term.keywords || '' keywords: term.keywords || ''
@ -88,7 +122,7 @@ export default function Wiki() {
} else { } else {
setEditingTerm(null); setEditingTerm(null);
setFormData({ setFormData({
key: '', term: '', definition: '', wikiId: 'general', metaTitle: '', metaDescription: '', keywords: '' key: '', term: '', definition: '', relatedProducts: [], wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
}); });
} }
setIsModalOpen(true); setIsModalOpen(true);
@ -151,7 +185,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={() => handleDelete(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button> <button onClick={() => setDeleteTargetKey(t.key)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@ -197,6 +231,43 @@ export default function Wiki() {
<textarea required value={formData.definition} onChange={(e) => setFormData({ ...formData, definition: e.target.value })} rows={5} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea> <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">
@ -223,6 +294,14 @@ export default function Wiki() {
</div> </div>
</div> </div>
)} )}
<ConfirmModal
isOpen={!!deleteTargetKey}
title="حذف اصطلاح علمی"
message="آیا از حذف این مورد مطمئن هستید؟ این عملیات قابل بازگشت نیست."
onConfirm={confirmDelete}
onCancel={() => setDeleteTargetKey(null)}
/>
</div> </div>
); );
} }

View File

@ -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, any>; details?: Array<{ field: string; message: string }> | Record<string, unknown>;
timestamp?: string; timestamp?: string;
path?: string; path?: string;
} }

View File

@ -9,11 +9,14 @@ 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,
@ -23,8 +26,9 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { fetchProfile } = useUserStore(); const { fetchProfile, role } = 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(() => {
@ -37,7 +41,13 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
} }
}, [fetchProfile, fetchSettings]); }, [fetchProfile, fetchSettings]);
// Remove full-page blocking screen to allow immediate render // Check Maintenance Mode (Admin / Partner bypass)
const isMaintenanceMode = getText('maintenance_mode', 'false') === 'true';
const isAdmin = role === 'User_Partner' || (typeof window !== 'undefined' && Boolean(localStorage.getItem('adminToken')));
if (isMaintenanceMode && !isAdmin) {
return <MaintenancePage />;
}
// Derived currentView from pathname for Header // Derived currentView from pathname for Header
let currentView = "home"; let currentView = "home";
@ -103,9 +113,13 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
onLogin={() => { onLogin={() => {
setLoginModalOpen(false); setLoginModalOpen(false);
}} }}
petName={advisorData?.name} petName={advisorData?.name as string | undefined}
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 />
</> </>
); );

View File

@ -2,12 +2,14 @@
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);
@ -89,6 +91,7 @@ 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"

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Mail, Phone, MapPin, Clock, Send } from "lucide-react"; import ContactFormClient from "@/components/ContactFormClient";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "تماس با ما - نمایندگی رسمی مکمل‌های کانینا آلمان در ایران", title: "تماس با ما - نمایندگی رسمی مکمل‌های کانینا آلمان در ایران",
@ -35,83 +35,10 @@ export default function ContactPage() {
</p> </p>
</div> </div>
<div className="grid lg:grid-cols-12 gap-12 items-start"> <ContactFormClient />
{/* 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>
); );
} }

View File

@ -102,9 +102,9 @@
font-family: "Vazirmatn" !important; font-family: "Vazirmatn" !important;
} }
html, body, #root { html, body {
font-family: "Vazirmatn" !important; font-family: "Vazirmatn" !important;
@apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw]; @apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw] overflow-x-hidden;
font-feature-settings: "ss01", "ss02", "cv01", "cv11"; font-feature-settings: "ss01", "ss02", "cv01", "cv11";
} }
@ -175,3 +175,22 @@
.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;
}

View File

@ -11,7 +11,7 @@ export async function generateMetadata(
if (!product) { if (!product) {
return { return {
title: 'محصول یافت نشد', title: 'خرید مکمل درمانی پت | کانینا ایران',
}; };
} }

View File

@ -26,6 +26,7 @@ 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> = {
@ -162,8 +163,12 @@ 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">
<span className="font-black text-medical-gray-900 font-vazir text-sm sm:text-base">{product.price}</span> {!useSettingsStore.getState().getText("catalog_hide_prices", "false").includes("true") ? (
{(() => { <span className="font-black text-medical-gray-900 font-vazir text-sm sm:text-base">{product.price}</span>
) : (
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-md">تماس جهت استعلام قیمت</span>
)}
{!useSettingsStore.getState().getText("catalog_disable_cart", "false").includes("true") && (() => {
const cartItem = useCartStore.getState().items.find(i => i.product.id === product.id); const cartItem = useCartStore.getState().items.find(i => i.product.id === product.id);
if (cartItem && cartItem.quantity > 0) { if (cartItem && cartItem.quantity > 0) {
return ( return (
@ -237,6 +242,8 @@ 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 });
@ -336,6 +343,8 @@ export default function ArchivePage({
category: selectedCategory, category: selectedCategory,
petType: selectedPet, petType: selectedPet,
query: searchQuery, query: searchQuery,
sortBy,
sortOrder,
limit: 999, limit: 999,
}); });
@ -364,7 +373,7 @@ export default function ArchivePage({
useEffect(() => { useEffect(() => {
fetchProducts(); fetchProducts();
}, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter]); }, [selectedCategory, selectedPet, searchQuery, activeSymptoms, prescriptionFilter, sortBy, sortOrder]);
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]);
@ -410,9 +419,17 @@ 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 ? 'block' : 'hidden lg:block'}`}> <aside className={`lg:w-72 flex-shrink-0 space-y-6 ${isMobileFilterOpen ? 'fixed inset-y-0 right-0 z-50 w-4/5 max-w-xs bg-white p-6 overflow-y-auto shadow-2xl space-y-6' : 'hidden lg:block'}`}>
<div className="bg-white rounded-[2rem] p-6 border border-medical-gray-200 shadow-sm"> <div className="bg-white rounded-[2rem] lg:p-6 border-0 lg:border lg:border-medical-gray-200 shadow-none lg:shadow-sm">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center 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" />
@ -420,7 +437,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" className="lg:hidden text-xs text-medical-gray-400 font-bold p-1 hover:text-red-500"
> >
بستن ✕ بستن ✕
</button> </button>
@ -541,17 +558,40 @@ export default function ArchivePage({
{/* Main Content */} {/* Main Content */}
<main className="flex-1 relative"> <main className="flex-1 relative">
<div className="flex flex-col md:flex-row items-center justify-between mb-6 gap-4"> <div className="flex flex-col sm:flex-row items-center justify-between mb-6 gap-4">
<h2 className="text-3xl font-black text-medical-gray-900">کاتولوگ دارویی <span className="text-canina-blue">Canina</span></h2> <h2 className="text-3xl font-black text-medical-gray-900">کاتولوگ دارویی <span className="text-canina-blue">Canina</span></h2>
<div className="relative w-full md:w-80">
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" /> <div className="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full sm:w-auto">
<input {/* Sort Dropdown */}
type="text" <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">
placeholder="جستجوی محصول..." <span className="text-medical-gray-400 whitespace-nowrap">مرتب‌سازی:</span>
value={searchQuery} <select
onChange={(e) => setSearchQuery(e.target.value)} value={`${sortBy}:${sortOrder}`}
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" onChange={(e) => {
/> const [b, o] = e.target.value.split(':');
setSortBy(b);
setSortOrder(o as 'asc' | 'desc');
}}
className="bg-transparent font-black text-medical-gray-800 outline-none cursor-pointer"
>
<option value="createdAt:desc">جدیدترین</option>
<option value="createdAt:asc">قدیمی‌ترین</option>
<option value="priceValue:asc">ارزان‌ترین</option>
<option value="priceValue:desc">گران‌ترین</option>
<option value="nameFa:asc">نام (الف - ی)</option>
</select>
</div>
<div className="relative w-full sm:w-64">
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
<input
type="text"
placeholder="جستجوی محصول..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-full py-2.5 pr-11 pl-4 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
/>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,38 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { cn } from "../lib/utils";
interface BackButtonProps {
className?: string;
label?: string;
}
export default function BackButton({ className, label = "بازگشت" }: BackButtonProps) {
const router = useRouter();
const handleBack = () => {
// router.back() preserves browser history and scroll position natively
if (typeof window !== "undefined" && window.history.length > 1) {
router.back();
} else {
router.push("/");
}
};
return (
<button
type="button"
onClick={handleBack}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm border border-medical-gray-200 hover:border-canina-blue hover:text-canina-blue text-medical-gray-700 text-xs font-black rounded-xl transition-all shadow-xs active:scale-95 whitespace-nowrap cursor-pointer",
className
)}
>
<ArrowRight className="w-4 h-4" />
<span>{label}</span>
</button>
);
}

View File

@ -0,0 +1,248 @@
"use client";
import { useEffect, useState } from "react";
import { Mail, Phone, MapPin, Send, CheckCircle2 } from "lucide-react";
import axios from "axios";
interface ContactInfoItem {
key: string;
title: string;
value: string;
icon?: string;
}
export default function ContactFormClient() {
const [infoItems, setInfoItems] = useState<ContactInfoItem[]>([]);
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const API_URL = process.env.NEXT_PUBLIC_API_URL || "";
useEffect(() => {
async function fetchContactInfo() {
try {
const res = await axios.get(`${API_URL}/contact/info`);
setInfoItems(res.data || []);
} catch (err) {
console.error("Failed to fetch contact info", err);
}
}
fetchContactInfo();
}, [API_URL]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !phone.trim() || !message.trim()) {
setError("لطفاً نام، شماره موبایل و متن پیام را وارد کنید.");
return;
}
setError("");
setLoading(true);
try {
await axios.post(`${API_URL}/contact`, {
name,
phone,
subject,
message,
});
setSuccess(true);
setName("");
setPhone("");
setSubject("");
setMessage("");
} catch (err: any) {
setError(
err.response?.data?.message || "خطایی در ثبت پیام رخ داده است. لطفاً مجدداً تلاش کنید."
);
} finally {
setLoading(false);
}
};
const getIcon = (iconName?: string) => {
switch (iconName) {
case "mail":
return <Mail className="w-6 h-6" />;
case "map-pin":
return <MapPin className="w-6 h-6" />;
case "phone":
default:
return <Phone className="w-6 h-6" />;
}
};
return (
<div className="grid lg:grid-cols-12 gap-12 items-start">
{/* Contact Details */}
<div className="lg:col-span-5 space-y-8">
<h3 className="text-2xl font-black text-medical-gray-900">اطلاعات نمایندگی</h3>
<div className="space-y-6">
{infoItems.length > 0 ? (
infoItems.map((item) => (
<div
key={item.key}
className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm"
>
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
{getIcon(item.icon)}
</div>
<div>
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
{item.title}
</h4>
<div className="text-sm font-bold text-medical-gray-900 leading-relaxed whitespace-pre-wrap">
{item.value}
</div>
</div>
</div>
))
) : (
<>
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
<Phone className="w-6 h-6" />
</div>
<div>
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
تلفن‌های تماس
</h4>
<p className="text-lg font-black text-medical-gray-900 tracking-wider">
۰۲۱-۸۸۸۸ ۴۴۴۴
</p>
<p className="text-sm text-medical-gray-500 font-bold">
شنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰
</p>
</div>
</div>
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
<Mail className="w-6 h-6" />
</div>
<div>
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
پست الکترونیک
</h4>
<p className="text-sm font-black text-medical-gray-900">info@canina-iran.com</p>
<p className="text-xs text-medical-gray-500 font-bold">
پاسخگویی در کمتر از ۲۴ ساعت کاری
</p>
</div>
</div>
<div className="flex gap-4 p-6 bg-white border border-medical-gray-200 rounded-[2rem] shadow-sm">
<div className="w-12 h-12 bg-canina-blue/5 text-canina-blue rounded-2xl flex items-center justify-center shrink-0">
<MapPin className="w-6 h-6" />
</div>
<div>
<h4 className="text-xs font-black text-medical-gray-400 uppercase tracking-wider mb-1">
نشانی دفتر مرکزی
</h4>
<p className="text-sm font-bold text-medical-gray-900 leading-relaxed">
تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹
</p>
</div>
</div>
</>
)}
</div>
</div>
{/* Contact Form */}
<div className="lg:col-span-7 bg-white border border-medical-gray-200 rounded-[3rem] p-10 shadow-xl">
<h3 className="text-2xl font-black text-medical-gray-900 mb-8">ارسال پیام مستقیم</h3>
{success ? (
<div className="p-6 bg-emerald-50 border border-emerald-200 text-emerald-800 rounded-2xl text-center space-y-3">
<CheckCircle2 className="w-12 h-12 text-emerald-600 mx-auto" />
<h4 className="text-lg font-black">پیام شما با موفقیت ثبت شد</h4>
<p className="text-sm font-medium leading-relaxed">
پیامک تایید به شماره شما ارسال شد. کارشناسان کانینا به زودی با شما تماس خواهند گرفت.
</p>
<button
onClick={() => setSuccess(false)}
className="mt-4 px-6 py-2 bg-emerald-600 text-white rounded-xl text-xs font-black"
>
ارسال پیام جدید
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-rose-50 border border-rose-200 text-rose-700 text-xs font-bold rounded-2xl">
{error}
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">
نام و نام خانوادگی *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
placeholder="مثال: علی محمدی"
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">شماره موبایل *</label>
<input
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold text-left focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
dir="ltr"
placeholder="09123456789"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">موضوع پیام</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
placeholder="مثال: مشاوره تخصصی مکمل یا خرید عمده"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-medical-gray-500">متن پیام *</label>
<textarea
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-4 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-canina-blue/20"
placeholder="متن درخواست یا پیام خود را بنویسید..."
required
></textarea>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-canina-blue text-white py-4 rounded-2xl font-black flex items-center justify-center gap-2 hover:shadow-xl hover:shadow-canina-blue/20 transition-all disabled:opacity-50"
>
<Send className="w-5 h-5 rotate-180" />
{loading ? "در حال ثبت..." : "ارسال پیام"}
</button>
</form>
)}
</div>
</div>
);
}

View File

@ -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"> <div className="relative mb-12 overflow-hidden">
<div className="text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div> <div className="text-[7rem] sm:text-[15rem] font-black text-medical-gray-100 leading-none select-none">۴۰۴</div>
<div className="absolute inset-0 flex items-center justify-center"> <div className="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" />

View File

@ -11,6 +11,7 @@ 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";
@ -132,11 +133,8 @@ 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">
{/* Top Free Shipping Notice */} {/* Sliding Announcement Ticker Banner */}
<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"> <TickerBanner />
<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 */}
@ -400,47 +398,56 @@ export default function Header({
</div> </div>
</header> </header>
{/* Mobile Navigation Drawer */} {/* Mobile Navigation Drawer & Backdrop */}
<AnimatePresence> <AnimatePresence>
{isMobileMenuOpen && ( {isMobileMenuOpen && (
<motion.div <>
initial={{ opacity: 0, height: 0 }} <motion.div
animate={{ opacity: 1, height: "auto" }} initial={{ opacity: 0 }}
exit={{ opacity: 0, height: 0 }} animate={{ opacity: 1 }}
className="lg:hidden bg-white border-b border-medical-gray-200 px-4 py-6 space-y-4" exit={{ opacity: 0 }}
> onClick={() => setIsMobileMenuOpen(false)}
<form onSubmit={handleSearch} className="relative mb-4"> className="lg:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-xs"
<input />
type="text" <motion.div
placeholder="جستجو در محصولات..." initial={{ opacity: 0, height: 0 }}
value={searchQuery} animate={{ opacity: 1, height: "auto" }}
onChange={(e) => setSearchQuery(e.target.value)} exit={{ opacity: 0, height: 0 }}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-10 pl-4 text-xs font-bold" className="lg:hidden relative z-50 bg-white border-b border-medical-gray-200 px-4 py-6 space-y-4 shadow-xl"
/> >
<Search className="w-4 h-4 text-medical-gray-400 absolute right-3 top-1/2 -translate-y-1/2" /> <form onSubmit={handleSearch} className="relative mb-4">
</form> <input
type="text"
placeholder="جستجو در محصولات..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-10 pl-4 text-xs font-bold"
/>
<Search className="w-4 h-4 text-medical-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
</form>
<div className="space-y-2 text-sm font-black"> <div className="space-y-2 text-sm font-black">
<Link href="/shop" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900"> <Link href="/shop" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900">
🛒 محصولات تخصصی کانینا 🛒 محصولات تخصصی کانینا
</Link> </Link>
<Link href="/catalog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> <Link href="/catalog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
📑 کاتالوگ آنلاین و دوز مصرفی 📑 کاتالوگ آنلاین و دوز مصرفی
</Link> </Link>
<Link href="/wiki" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> <Link href="/wiki" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
🧬 دانشنامه علمی 🧬 دانشنامه علمی
</Link> </Link>
<Link href="/blog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> <Link href="/blog" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
📰 مجله سلامت پت 📰 مجله سلامت پت
</Link> </Link>
<Link href="/videos" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> <Link href="/videos" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
🎥 آکادمی ویدئویی و مشاوره دامپزشک 🎥 آکادمی ویدئویی و مشاوره دامپزشک
</Link> </Link>
<Link href="/profile" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> <Link href="/profile" onClick={() => setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800">
🐾 شناسنامه و سوابق سلامت پت 🐾 شناسنامه و سوابق سلامت پت
</Link> </Link>
</div> </div>
</motion.div> </motion.div>
</>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>

View File

@ -33,6 +33,64 @@ 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) {
@ -79,26 +137,7 @@ 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();
const cleanCode = otpCode.trim(); handleVerifyOtpWithCode(otpCode);
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 () => {
@ -234,9 +273,11 @@ 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={(e) => setOtpCode(e.target.value.replace(/[^0-9]/g, ''))} onChange={handleOtpInputChange}
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]"
/> />

View File

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { Wrench, ShieldCheck, Phone, ArrowRight } from "lucide-react";
import Link from "next/link";
export default function MaintenancePage() {
return (
<div className="min-h-screen bg-medical-gray-900 text-white flex flex-col items-center justify-center p-6 text-center font-vazir relative overflow-hidden" dir="rtl">
{/* Glow Effects */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-canina-blue/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-10 right-10 w-80 h-80 bg-canina-gold/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-xl mx-auto space-y-6">
<div className="w-24 h-24 bg-white/10 border border-white/20 rounded-3xl mx-auto flex items-center justify-center text-canina-gold shadow-2xl backdrop-blur-md">
<Wrench className="w-12 h-12 animate-bounce" />
</div>
<span className="inline-block px-4 py-1.5 bg-canina-gold/20 text-canina-gold border border-canina-gold/30 rounded-full text-xs font-black tracking-widest uppercase">
سامانه در حال به‌روزرسانی و ارتقا
</span>
<h1 className="text-3xl sm:text-5xl font-black tracking-tight leading-tight">
به‌زودی با خدمات کامل‌تر بازمی‌گردیم
</h1>
<p className="text-sm sm:text-base text-medical-gray-300 leading-relaxed">
وب‌سایت کانینا ایران جهت ارتقای زیرساخت‌ها و ارائه خدمات بهتر به شما سرپرستان و دامپزشکان محترم به صورت موقت در دست به‌روزرسانی است. از شکیبایی شما سپاسگزاریم.
</p>
<div className="pt-4 flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href="tel:0218888"
className="w-full sm:w-auto px-6 py-3.5 bg-canina-blue hover:bg-canina-dark text-white font-black text-xs rounded-xl flex items-center justify-center gap-2 shadow-lg transition-all"
>
<Phone className="w-4 h-4" />
<span>پشتیبانی اضطراری: ۰۲۱-۸۸۸۸</span>
</a>
</div>
</div>
<div className="absolute bottom-6 text-xs text-medical-gray-500 font-bold flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-canina-gold" />
<span>CANINA PHARMA GMBH GERMANY — نماینده رسمی ایران</span>
</div>
</div>
);
}

View File

@ -72,13 +72,6 @@ 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);
@ -121,54 +114,10 @@ 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 [];
let picks: { product: Product, reason?: string }[] = []; const 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) {
@ -220,6 +169,50 @@ 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({
@ -256,6 +249,14 @@ export default function PetProfile({ initialView, advisorNeed }: {
} }
}; };
if (isLoading && view === "detail" && activePet) {
return (
<div className="max-w-6xl mx-auto px-4 py-20" dir="rtl">
<PetProfileSkeleton />
</div>
);
}
// 1. Pet Index Page // 1. Pet Index Page
if (view === "index") { if (view === "index") {
return ( return (

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { useSettingsStore } from "../lib/store/settingsStore";
import { Sparkles } from "lucide-react";
export default function TickerBanner() {
const getText = useSettingsStore((state) => state.getText);
const announcementText = getText(
"shipping_notice",
"ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان — گارانتی اصالت کانینا آلمان — مشاوره تخصصی مکمل‌های دارویی سگ و گربه با کادر دامپزشکان مجرب"
);
return (
<div className="bg-canina-gold text-canina-dark text-xs font-black py-1.5 overflow-hidden relative shadow-xs border-b border-amber-300/40 select-none">
<div className="flex items-center gap-2 whitespace-nowrap animate-marquee">
<span className="flex items-center gap-2 px-4">
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
{announcementText}
</span>
<span className="flex items-center gap-2 px-4">
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
{announcementText}
</span>
<span className="flex items-center gap-2 px-4">
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
{announcementText}
</span>
<span className="flex items-center gap-2 px-4">
<Sparkles className="w-3.5 h-3.5 text-canina-dark animate-pulse inline" />
{announcementText}
</span>
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ 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;
@ -37,6 +38,10 @@ 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("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است");

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react"; import { motion } from "motion/react";
import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react"; import { 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, cn } from "../lib/utils"; import { toPersian, toEnglishDigits, 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,7 +16,15 @@ import { useRouter } from 'next/navigation';
export default function UserDashboard() { export default function UserDashboard() {
const router = useRouter(); const router = useRouter();
const { profile, logout, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore(); const { profile, isLoggedIn, logout, updateProfile, addAddress, updateAddress, deleteAddress, setDefaultAddress, topUpWallet } = useUserStore();
React.useEffect(() => {
if (!isLoggedIn && typeof window !== "undefined") {
router.push("/");
toast.error("برای دسترسی به داشبورد لطفاً ابتدا وارد حساب کاربری خود شوید.");
}
}, [isLoggedIn, router]);
const { orders } = useCartStore(); const { 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);
@ -24,8 +32,16 @@ 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") {
setIsLoadingOrders(true); let isSubscribed = true;
useUserStore.getState().fetchProfile().finally(() => setIsLoadingOrders(false)); Promise.resolve().then(() => {
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);
@ -42,36 +58,41 @@ 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(() => {
setProfileForm({ let isSubscribed = true;
firstName: profile.firstName || "", Promise.resolve().then(() => {
lastName: profile.lastName || "", if (isSubscribed) {
email: profile.email || "", setProfileForm({
mobile: profile.mobile || "" firstName: profile.firstName || "",
lastName: profile.lastName || "",
email: profile.email || "",
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 {
await useUserStore.getState().updateProfile({ const cleanPhone = toEnglishDigits(profileForm.mobile);
await updateProfile({
firstName: profileForm.firstName, firstName: profileForm.firstName,
lastName: profileForm.lastName, lastName: profileForm.lastName,
email: profileForm.email, email: profileForm.email,
@ -79,8 +100,8 @@ export default function UserDashboard() {
}); });
setIsEditing(false); setIsEditing(false);
toast.success("اطلاعات کاربری با موفقیت ویرایش شد"); toast.success("اطلاعات کاربری با موفقیت ویرایش شد");
} catch (err: any) { } catch {
toast.error(err.message || "خطا در ویرایش اطلاعات"); toast.error("خطا در ویرایش اطلاعات");
} finally { } finally {
setIsSavingProfile(false); setIsSavingProfile(false);
} }
@ -98,7 +119,7 @@ export default function UserDashboard() {
} else { } else {
await addAddress(addr); await addAddress(addr);
} }
} catch (err) { } catch {
toast.error("خطا در ثبت آدرس"); toast.error("خطا در ثبت آدرس");
} }
setEditingAddress(null); setEditingAddress(null);
@ -114,7 +135,7 @@ export default function UserDashboard() {
try { try {
await deleteAddress(addressToDelete.id); await deleteAddress(addressToDelete.id);
toast.success("آدرس با موفقیت حذف شد"); toast.success("آدرس با موفقیت حذف شد");
} catch (err) { } catch {
toast.error("خطا در حذف آدرس"); toast.error("خطا در حذف آدرس");
} }
setAddressToDelete(null); setAddressToDelete(null);
@ -357,7 +378,7 @@ export default function UserDashboard() {
<div className="text-sm font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors italic">سفارش {toPersian(order.trackingNumber || order.id?.substring(0, 8))}</div> <div className="text-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 any).createdAt).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as any).createdAt).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))} {toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleDateString("fa-IR"))} - ساعت {toPersian(new Date(order.date || (order as unknown as Record<string, string>).createdAt || 0).toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }))}
</div> </div>
</div> </div>
</div> </div>
@ -464,7 +485,7 @@ export default function UserDashboard() {
try { try {
await setDefaultAddress(addr.id); await setDefaultAddress(addr.id);
toast.success("آدرس پیش‌فرض با موفقیت تغییر کرد"); toast.success("آدرس پیش‌فرض با موفقیت تغییر کرد");
} catch (err) { } catch {
toast.error("خطا در تغییر آدرس پیش‌فرض"); toast.error("خطا در تغییر آدرس پیش‌فرض");
} }
}} }}
@ -514,18 +535,28 @@ 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;
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" /> }
برداشت وجه const withdrawAmountStr = prompt("لطفاً مبلغ مورد نظر برای برداشت (تومان) را وارد کنید:", "100000");
</button> if (!withdrawAmountStr) return;
<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"> const withdrawAmount = parseInt(withdrawAmountStr);
قابلیت برداشت وجه به‌زودی فعال خواهد شد. if (isNaN(withdrawAmount) || withdrawAmount > profile.walletBalance) {
</div> toast.error("مبلغ وارد شده معتبر نیست یا از موجودی کیف پول بیشتر است.");
</div> return;
}
const iban = prompt("لطفاً شماره شبا (IR...) جهت واریز را وارد کنید:");
if (!iban) return;
toast.success(`درخواست برداشت ${toPersian(withdrawAmount.toLocaleString())} تومان با موفقیت ثبت شد و پس از بررسی واریز خواهد شد.`);
}}
className="w-full md:w-40 h-12 sm:h-16 bg-white/10 backdrop-blur-md rounded-2xl font-black text-xs sm:text-sm flex items-center justify-center gap-2 border border-white/20 hover:bg-white/20 transition-all cursor-pointer"
>
<ArrowDownCircle className="w-4 h-4 sm:w-5 sm:h-5" />
برداشت وجه
</button>
<button <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"

View File

@ -1,13 +1,24 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { Play, PlayCircle, Star, ShieldCheck, X } from "lucide-react"; import { Play, Star, ShieldCheck, X } from "lucide-react";
import { motion, AnimatePresence } from "motion/react"; import { 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",
@ -38,11 +49,10 @@ const FALLBACK_VIDEOS = [
} }
]; ];
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) { export default function VetGallery({ testimonials = [] }: { testimonials?: TestimonialItem[] }) {
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); const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
React.useEffect(() => { React.useEffect(() => {
videoService.getVideos({ featured: true, limit: 3 }).then((data) => { videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
@ -55,13 +65,13 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
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);
@ -102,7 +112,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" /> <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"
/> />
@ -117,7 +127,7 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: any[]
</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?.substring(0, 40) + '...'} {video.title || (video.quote ? 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" />

View File

@ -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, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react"; import { ChevronRight, PlayCircle, ShieldCheck, X, Search, Clock, User } from "lucide-react";
import SafeImage from "./SafeImage"; import SafeImage from "./SafeImage";

View File

@ -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 '../../store/cartStore'; import { useCartStore } from '../../lib/store/cartStore';
import { productService } from '../../services/productService'; import { productService } from '../../lib/services/productService';
vi.mock('../../store/cartStore', () => ({ vi.mock('../../lib/store/cartStore', () => ({
useCartStore: vi.fn(), useCartStore: vi.fn(),
})); }));
vi.mock('../../services/productService', () => ({ vi.mock('../../lib/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([]); vi.mocked(productService.getProducts).mockResolvedValue({ data: [] } as any);
}); });
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 any); } as unknown as ReturnType<typeof useCartStore>);
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 any); } as unknown as ReturnType<typeof useCartStore>);
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 any); } as unknown as ReturnType<typeof useCartStore>);
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />); render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);

View File

@ -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 '../../services/productService'; import { productService } from '../../lib/services/productService';
import { usePetStore } from '../../store/usePetStore'; import { usePetStore } from '../../lib/store/usePetStore';
vi.mock('../../services/productService', () => ({ vi.mock('../../lib/services/productService', () => ({
productService: { productService: {
getFeaturedProducts: vi.fn(), getFeaturedProducts: vi.fn(),
}, },
})); }));
vi.mock('../../store/usePetStore', () => ({ vi.mock('../../lib/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 any); } as unknown as ReturnType<typeof usePetStore>);
}); });
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 any); vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
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 any); vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
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 any); vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as unknown as ReturnType<typeof productService.getFeaturedProducts>);
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />); render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
const navBtn = screen.getByText('مشاهده تمامی محصولات'); const navBtn = screen.getByText('مشاهده تمامی محصولات');

View File

@ -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 '../../store/cartStore'; import { useCartStore } from '../../lib/store/cartStore';
import { usePetStore } from '../../store/usePetStore'; import { usePetStore } from '../../lib/store/usePetStore';
import { useUserStore } from '../../store/userStore'; import { useUserStore } from '../../lib/store/userStore';
vi.mock('../../store/cartStore', () => ({ vi.mock('../../lib/store/cartStore', () => ({
useCartStore: vi.fn(), useCartStore: vi.fn(),
})); }));
vi.mock('../../store/usePetStore', () => ({ vi.mock('../../lib/store/usePetStore', () => ({
usePetStore: vi.fn(), usePetStore: vi.fn(),
})); }));
vi.mock('../../store/userStore', () => ({ vi.mock('../../lib/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 any); } as unknown as ReturnType<typeof useCartStore>);
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 any); } as unknown as ReturnType<typeof usePetStore>);
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 any); } as unknown as ReturnType<typeof useUserStore>);
}); });
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 any); } as unknown as ReturnType<typeof useUserStore>);
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 any); } as unknown as ReturnType<typeof usePetStore>);
render( render(
<Header <Header

View File

@ -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 '../../store/settingsStore'; import { useSettingsStore } from '../../lib/store/settingsStore';
vi.mock('../../store/settingsStore', () => ({ vi.mock('../../lib/store/settingsStore', () => ({
useSettingsStore: vi.fn(), useSettingsStore: vi.fn(),
})); }));
@ -21,7 +21,7 @@ describe('Hero', () => {
return fallback; return fallback;
}); });
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText })); vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
render(<Hero onShopNavigate={vi.fn()} />); 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: any) => selector({ getText: mockGetText })); vi.mocked(useSettingsStore).mockImplementation((selector: (state: { getText: (key: string, fallback: string) => string }) => unknown) => selector({ getText: mockGetText }) as ReturnType<typeof selector>);
const handleShopNavigate = vi.fn(); const handleShopNavigate = vi.fn();
render(<Hero onShopNavigate={handleShopNavigate} />); render(<Hero onShopNavigate={handleShopNavigate} />);

View File

@ -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 '../../store/settingsStore'; import { useSettingsStore } from '../../lib/store/settingsStore';
vi.mock('../../store/settingsStore', () => ({ vi.mock('../../lib/store/settingsStore', () => ({
useSettingsStore: vi.fn(), useSettingsStore: vi.fn(),
})); }));

View File

@ -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;
let qty = (weight / 10) * rate; const 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) => {
let qty = Math.max(1, weight / 10); const 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) => {
let tablets = (weight / 10) * 2; const 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) => {
let tsp = weight > 6 ? 1 : 0.5; const tsp = weight > 6 ? 1 : 0.5;
return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" }; return { quantity: tsp, unit: "قاشق چایخوری", description: "روزانه با غذا مخلوط شود" };
}, },
analysis: { "تورین": "۷۰۰ گرم/kg" }, analysis: { "تورین": "۷۰۰ گرم/kg" },

View File

@ -1,13 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
export function useNetworkStatus() { export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(() =>
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);

View File

@ -1,5 +1,6 @@
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');
@ -8,7 +9,7 @@ export interface ApiErrorPayload {
statusCode: number; statusCode: number;
message: string; message: string;
code: string; code: string;
details?: Array<{ field: string; message: string }> | Record<string, any>; details?: Array<{ field: string; message: string }> | Record<string, unknown>;
timestamp?: string; timestamp?: string;
path?: string; path?: string;
} }
@ -45,20 +46,28 @@ 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 // Handle 401 Unauthorized globally - trigger Auth Modal
if (status === 401 || status === 403) { if (status === 401) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
try {
useUserStore.getState().logout();
useUserStore.getState().setAuthModalOpen(true);
} catch {
// Ignore if store not ready
}
} else if (status === 403) {
localStorage.removeItem('accessToken'); localStorage.removeItem('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 // Show formatted Farsi toast if not explicitly suppressed and not 404
if (!error.config?.hideErrorToast) { if (!error.config?.hideErrorToast && status !== 404) {
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);

View File

@ -11,8 +11,8 @@ export interface User {
charityDonationTotal: number; charityDonationTotal: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
pets?: any[]; pets?: unknown[];
orders?: any[]; orders?: unknown[];
} }
export interface AuthResponse { export interface AuthResponse {
@ -23,6 +23,15 @@ 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;
@ -42,8 +51,9 @@ 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: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در ارسال کد تایید'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در ارسال کد تایید';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -59,8 +69,9 @@ export class AuthService {
localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('accessToken', data.accessToken);
} }
return response.data; return response.data;
} catch (error: any) { } catch (error) {
const message = error.response?.data?.message || 'کد تایید نامعتبر است'; const err = error as ApiErr;
const message = err.response?.data?.message || 'کد تایید نامعتبر است';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -68,7 +79,7 @@ export class AuthService {
/** /**
* Register with email/mobile and password * Register with email/mobile and password
*/ */
public async register(data: any): Promise<AuthResponse> { public async register(data: Record<string, unknown>): 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;
@ -76,8 +87,9 @@ export class AuthService {
localStorage.setItem('accessToken', resData.accessToken); localStorage.setItem('accessToken', resData.accessToken);
} }
return response.data; return response.data;
} catch (error: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در ثبت‌نام'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در ثبت‌نام';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -93,8 +105,9 @@ export class AuthService {
localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('accessToken', data.accessToken);
} }
return response.data; return response.data;
} catch (error: any) { } catch (error) {
const message = error.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است'; const err = error as ApiErr;
const message = err.response?.data?.message || 'نام کاربری یا رمز عبور اشتباه است';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -106,11 +119,12 @@ 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: any) { } catch (error) {
if (error.response?.status === 401 || error.response?.status === 403) { const err = error as ApiErr;
if (err.response?.status === 401 || err.response?.status === 403) {
return null; return null;
} }
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);
} }
} }
@ -122,8 +136,9 @@ 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: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در ویرایش اطلاعات کاربری';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }

View File

@ -1,12 +1,11 @@
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?: any; product?: Record<string, unknown>;
} }
export interface Order { export interface Order {
@ -21,6 +20,14 @@ 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;
@ -50,8 +57,9 @@ 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: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در ثبت سفارش'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در ثبت سفارش';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -63,8 +71,9 @@ 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: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارش‌ها'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در دریافت لیست سفارش‌ها';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }
@ -76,8 +85,9 @@ 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: any) { } catch (error) {
const message = error.response?.data?.message || 'خطا در دریافت جزئیات سفارش'; const err = error as ApiErr;
const message = err.response?.data?.message || 'خطا در دریافت جزئیات سفارش';
throw new Error(Array.isArray(message) ? message[0] : message); throw new Error(Array.isArray(message) ? message[0] : message);
} }
} }

View File

@ -23,18 +23,19 @@ export class ProductService {
return ProductService.instance; return ProductService.instance;
} }
private mapBackendToFrontend(data: any): Product { private mapBackendToFrontend(inputData: Record<string, unknown>): Product {
const data = inputData as any;
// Find the local static product to inherit functions like calculateDosage // 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 ||
(data.slug && data.slug.includes(p.id)) (typeof data.slug === 'string' && data.slug.includes(p.id))
); );
const safeParse = (val: any, fallback: any) => { const safeParse = (val: unknown, fallback: unknown) => {
if (!val) return fallback; if (!val) return fallback;
if (typeof val === 'string') { if (typeof val === 'string') {
try { return JSON.parse(val); } catch (e) { return fallback; } try { return JSON.parse(val); } catch { return fallback; }
} }
return val; return val;
}; };
@ -68,8 +69,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: any) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [], main_ingredients: data.ingredientList?.map((i: { ingredient: string }) => i.ingredient) || data.ingredients?.split(/[،,-]/).map((s: string) => s.trim()).filter(Boolean) || [],
symptoms: data.symptoms?.map((s: any) => s.symptom || s) || [], symptoms: data.symptoms?.map((s: { symptom?: string }) => 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, []),
@ -90,19 +91,23 @@ 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: any) => this.mapBackendToFrontend(item)), data: response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item)),
meta: response.data.meta, meta: response.data.meta,
}; };
} catch (error) { } catch (error) {
@ -134,7 +139,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: any) => this.mapBackendToFrontend(item)); const all = response.data.data.map((item: Record<string, unknown>) => this.mapBackendToFrontend(item));
const featured: Product[] = []; const featured: Product[] = [];
const categoriesSeen = new Set<string>(); const categoriesSeen = new Set<string>();

View File

@ -32,11 +32,11 @@ const mockProduct = {
dosage_logic: '1', dosage_logic: '1',
benefits: '', benefits: '',
symptoms: [], symptoms: [],
suitableFor: 'سگ' as any, suitableFor: 'سگ' as never,
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }), calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
analysis: {}, analysis: {},
feedingAdvice: '', feedingAdvice: '',
specialist: null as any, specialist: null as never,
image: '', image: '',
}; };

View File

@ -79,6 +79,7 @@ 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();

View File

@ -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: any[]) => void; setOrders: (orders: Record<string, unknown>[]) => void;
getTotalItems: () => number; getTotalItems: () => number;
getSubtotal: () => number; getSubtotal: () => number;
getDiscount: () => number; getDiscount: () => number;
@ -120,6 +120,7 @@ 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,
@ -127,15 +128,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: backendOrder.status as any, status: (['processing', 'shipped', 'delivered'].includes(backendOrder.status) ? backendOrder.status : 'processing') as Order['status'],
isRefill: (backendOrder as any).isRefill !== undefined ? Boolean((backendOrder as any).isRefill) : Boolean(orderData.isRefill || get().isSubscribed), isRefill: bo.isRefill !== undefined ? Boolean(bo.isRefill) : Boolean(orderData.isRefill || get().isSubscribed),
paymentMethod: (backendOrder as any).paymentMethod || orderData.paymentMethod, paymentMethod: (bo.paymentMethod as string) || 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: any) { } catch (error) {
console.error("Order creation failed on backend:", error); console.error("Order creation failed on backend:", error);
throw error; throw error;
} }
@ -144,20 +145,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: bo.id, id: String(bo.id),
date: bo.createdAt, date: String(bo.createdAt),
items: bo.orderItems?.map((oi: any) => ({ items: (bo.orderItems as Array<{ product: Product; quantity: number }>)?.map((oi) => ({
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: bo.status, status: (['processing', 'shipped', 'delivered'].includes(String(bo.status)) ? bo.status : 'processing') as Order['status'],
isRefill: Boolean((bo as any).isRefill), isRefill: Boolean(bo.isRefill),
paymentMethod: (bo as any).paymentMethod, paymentMethod: bo.paymentMethod as string | undefined,
petId: bo.petId || bo.pet?.id, petId: (bo.petId as string) || ((bo.pet as { id?: string })?.id),
shippingAddress: bo.shippingAddress, shippingAddress: bo.shippingAddress as string | undefined,
trackingNumber: bo.trackingNumber || `CN-${Math.floor(Math.random() * 90000) + 10000}` trackingNumber: (bo.trackingNumber as string) || `CN-${Math.floor(Math.random() * 90000) + 10000}`
})); }));
set({ orders: mappedOrders }); set({ orders: mappedOrders });
}, },

View File

@ -4,9 +4,9 @@ interface UIState {
isCartOpen: boolean; isCartOpen: boolean;
isLoginModalOpen: boolean; isLoginModalOpen: boolean;
isB2BPortalOpen: boolean; isB2BPortalOpen: boolean;
advisorData: any; advisorData: Record<string, unknown> | null;
setCartOpen: (open: boolean) => void; setCartOpen: (open: boolean) => void;
setLoginModalOpen: (open: boolean, advisorData?: any) => void; setLoginModalOpen: (open: boolean, advisorData?: Record<string, unknown> | null) => void;
setB2BPortalOpen: (open: boolean) => void; setB2BPortalOpen: (open: boolean) => void;
clearAdvisorData: () => void; clearAdvisorData: () => void;
} }

View File

@ -3,6 +3,7 @@ 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;
@ -54,7 +55,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: any[]) => void; setPets: (pets: Record<string, unknown>[]) => void;
reset: () => void; reset: () => void;
} }
@ -125,7 +126,6 @@ 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: bp.id, id: String(bp.id),
name: bp.name, name: String(bp.name),
type: bp.type as any, type: (bp.type as PetProfile['type']) || "سگ",
breed: bp.breed, breed: String(bp.breed || ''),
age: Number(bp.age), age: Number(bp.age || 0),
weight: Number(bp.weight), weight: Number(bp.weight || 0),
activityLevel: bp.activityLevel as any, activityLevel: (bp.activityLevel as PetProfile['activityLevel']) || "متوسط",
medicalConditions: bp.medicalConditions?.map((mc: any) => mc.condition) || [], medicalConditions: (bp.medicalConditions as Array<{ condition: string }>)?.map((mc) => mc.condition) || [],
image: bp.imageUrl || undefined, image: (bp.imageUrl as string) || undefined,
reminders: bp.reminders?.map((r: any) => ({ reminders: (bp.reminders as Array<{ id: string; title: string; time: string; frequency: Reminder['frequency']; productId?: string; completions?: Array<{ completedDate: string }> }>)?.map((r) => ({
id: r.id, id: r.id,
title: r.title, title: r.title,
time: r.time, time: r.time,
frequency: r.frequency as any, frequency: r.frequency,
productId: r.productId, productId: r.productId,
completedDates: r.completions?.map((c: any) => c.completedDate) || [] completedDates: r.completions?.map((c) => c.completedDate) || []
})) || [], })) || [],
logs: bp.healthLogs?.map((hl: any) => ({ logs: (bp.healthLogs as Array<{ id: string; loggedDate: string; appetite: HealthLog['appetite']; energy: HealthLog['energy']; digestion: HealthLog['digestion']; note?: string }>)?.map((hl) => ({
id: hl.id, id: hl.id,
date: hl.loggedDate, date: hl.loggedDate,
appetite: hl.appetite as any, appetite: hl.appetite,
energy: hl.energy as any, energy: hl.energy,
digestion: hl.digestion as any, digestion: hl.digestion,
note: hl.note || undefined note: hl.note || undefined
})) || [], })) || [],
consumptions: existingLocal?.consumptions || [] consumptions: existingLocal?.consumptions || []

View File

@ -44,9 +44,11 @@ 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>;
@ -62,6 +64,7 @@ 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: "",
@ -74,6 +77,7 @@ 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);
@ -94,7 +98,16 @@ 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 { id, ...addressData } = address; const addressData = {
title: address.title,
receptorName: address.receptorName,
phone: address.phone,
province: address.province,
city: address.city,
detail: address.detail,
zipCode: address.zipCode,
isDefault: address.isDefault,
};
await api.post('/users/addresses', addressData); await api.post('/users/addresses', addressData);
await useUserStore.getState().fetchProfile(); await useUserStore.getState().fetchProfile();
} catch (error) { } catch (error) {
@ -104,7 +117,16 @@ export const useUserStore = create<UserStore>()(
}, },
updateAddress: async (id, updated) => { updateAddress: async (id, updated) => {
try { try {
const { id: _, ...addressData } = updated; const addressData = {
title: updated.title,
receptorName: updated.receptorName,
phone: updated.phone,
province: updated.province,
city: updated.city,
detail: updated.detail,
zipCode: updated.zipCode,
isDefault: updated.isDefault,
};
await api.patch(`/users/addresses/${id}`, addressData); await api.patch(`/users/addresses/${id}`, addressData);
await useUserStore.getState().fetchProfile(); await useUserStore.getState().fetchProfile();
} catch (error) { } catch (error) {
@ -160,7 +182,7 @@ export const useUserStore = create<UserStore>()(
}, },
fetchProfile: async () => { fetchProfile: async () => {
try { try {
const token = localStorage.getItem('accessToken'); const token = typeof window !== 'undefined' && window.localStorage ? localStorage.getItem('accessToken') : (globalThis as any)._testToken;
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 {}
@ -201,14 +223,15 @@ 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 = (profileData as any).walletTransactions?.map((t: any) => ({ const backendTransactions = (pd.walletTransactions as Array<{ id: string; type: string; amount: number; createdAt: string; status: string }>)?.map((t) => ({
id: t.id, id: t.id,
type: t.type === 'deposit' ? 'top_up' : 'purchase', type: (t.type === 'deposit' ? 'top_up' : 'purchase') as Transaction['type'],
amount: Number(t.amount), amount: Number(t.amount),
date: t.createdAt, date: t.createdAt,
status: t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending' status: (t.status === 'completed' ? 'success' : t.status === 'failed' ? 'failed' : 'pending') as Transaction['status']
})) || []; })) || [];
return { return {
@ -221,7 +244,7 @@ export const useUserStore = create<UserStore>()(
mobile: profileData.mobile || "", mobile: profileData.mobile || "",
walletBalance: backendWallet, walletBalance: backendWallet,
charityDonationTotal: backendCharity, charityDonationTotal: backendCharity,
addresses: (profileData as any).addresses?.length > 0 ? (profileData as any).addresses : state.profile.addresses, addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses,
transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions
} }
}; };
@ -230,11 +253,11 @@ export const useUserStore = create<UserStore>()(
// Sync orders list to CartStore while preserving local petId & address mappings // 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.map((bo: any) => { const mergedOrders = (profileData.orders as Record<string, unknown>[]).map((bo) => {
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?.id || localMatch?.petId, petId: bo.petId || (bo.pet as { id?: string })?.id || localMatch?.petId,
shippingAddress: bo.shippingAddress || localMatch?.shippingAddress shippingAddress: bo.shippingAddress || localMatch?.shippingAddress
}; };
}); });
@ -242,7 +265,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); usePetStore.getState().setPets(profileData.pets as Record<string, unknown>[]);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch user profile:", error); console.error("Failed to fetch user profile:", error);

View File

@ -3,6 +3,12 @@ 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(" ");
} }

File diff suppressed because it is too large Load Diff

View File

@ -20,12 +20,17 @@
}, },
"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"
} }
} }

View File

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules", "**/__tests__/**"]
} }

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
});

View File

@ -0,0 +1,25 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
configurable: true,
value: MockIntersectionObserver,
});
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));