Compare commits

..

No commits in common. "187aa7c962322a605e4918144c654caef9ebf57e" and "ce30ade80880d98da8809563fd01d59dd328178e" have entirely different histories.

13 changed files with 60 additions and 428 deletions

View File

@ -76,23 +76,6 @@ export class AdminController {
}; };
} }
@UseGuards(JwtAuthGuard)
@Post('users/:id/wallet-adjust')
@ApiOperation({ summary: 'شارژ یا کسر مستقیم کیف پول کاربر توسط ادمین' })
async adjustWallet(
@Param('id') id: string,
@Body('amount') amount: number,
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
@Body('description') description?: string,
) {
const result = await this.adminService.adjustUserWallet(id, Number(amount), type, description);
return {
success: true,
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
data: result,
};
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('products') @Get('products')
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' }) @ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
@ -254,21 +237,6 @@ export class AdminController {
return { success: true, data: settings }; return { success: true, data: settings };
} }
@UseGuards(JwtAuthGuard)
@Get('pets')
@ApiOperation({ summary: 'لیست پت‌ها (مدیریت)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'search', required: false })
async getPets(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
) {
const data = await this.adminService.getPets({ page, limit, search });
return { success: true, ...data };
}
// --- Doctors / Vets --- // --- Doctors / Vets ---
@Get('doctors') @Get('doctors')
@ApiOperation({ summary: 'لیست پزشکان و متخصصان' }) @ApiOperation({ summary: 'لیست پزشکان و متخصصان' })

View File

@ -85,13 +85,6 @@ export class AdminService {
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: {
addresses: true,
walletTransactions: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
}), }),
this.prisma.user.count({ where }), this.prisma.user.count({ where }),
]); ]);
@ -102,38 +95,6 @@ export class AdminService {
}; };
} }
async adjustUserWallet(userId: string, amount: number, type: 'deposit' | 'withdrawal' | 'refund', description?: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('کاربر مورد نظر یافت نشد');
}
const adjustAmount = type === 'withdrawal' ? -Math.abs(amount) : Math.abs(amount);
const currentBalance = Number(user.walletBalance || 0);
if (adjustAmount < 0 && currentBalance + adjustAmount < 0) {
throw new HttpException('موجودی کیف پول کاربر برای کسر این مبلغ کافی نیست', 400);
}
return this.prisma.$transaction([
this.prisma.walletTransaction.create({
data: {
userId,
amount: Math.abs(amount),
type: type === 'withdrawal' ? 'withdrawal' : 'deposit',
status: 'completed',
description: description || (type === 'refund' ? 'بازگشت وجه توسط ادمین' : type === 'deposit' ? 'شارژ توسط ادمین' : 'کسر توسط ادمین'),
},
}),
this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: { increment: adjustAmount },
},
}),
]);
}
async updateUserRole(id: string, role: string) { async updateUserRole(id: string, role: string) {
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
@ -475,48 +436,6 @@ export class AdminService {
return this.getSettings(); return this.getSettings();
} }
async getPets(query: any) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: any = {};
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ breed: { contains: query.search, mode: 'insensitive' } },
{ user: { firstName: { contains: query.search, mode: 'insensitive' } } },
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
];
}
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: { id: true, firstName: true, lastName: true, mobile: true, email: true },
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
}
async getDoctors() { async getDoctors() {
return this.prisma.doctor.findMany({ return this.prisma.doctor.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },

View File

@ -20,6 +20,7 @@ export class AuthService {
const { phoneNumber } = sendOtpDto; const { phoneNumber } = sendOtpDto;
const code = Math.floor(10000 + Math.random() * 90000).toString(); const code = Math.floor(10000 + Math.random() * 90000).toString();
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
await this.redisService.set(`otp:${phoneNumber}`, code, 120); await this.redisService.set(`otp:${phoneNumber}`, code, 120);
@ -168,30 +169,10 @@ export class AuthService {
} }
async adminLogin(body: any) { async adminLogin(body: any) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com'; if (
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; body.email === 'admin@canino-iran.com' &&
body.password === 'admin123'
// First check database for user with role 'Admin' or 'SUPER_ADMIN' ) {
const dbAdmin = await this.prisma.user.findFirst({
where: { email: body.email, role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] } },
});
if (dbAdmin && dbAdmin.password) {
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
if (isMatch) {
const payload = { sub: dbAdmin.id, email: dbAdmin.email, role: dbAdmin.role };
return {
success: true,
data: {
user: dbAdmin,
accessToken: this.jwtService.sign(payload),
},
};
}
}
// Fallback to validated environment variable credentials
if (body.email === adminEmail && body.password === adminPassword) {
const payload = { const payload = {
sub: '12345678-1234-1234-1234-123456789012', sub: '12345678-1234-1234-1234-123456789012',
email: body.email, email: body.email,
@ -209,7 +190,6 @@ export class AuthService {
}, },
}; };
} }
throw new BadRequestException({ throw new BadRequestException({
message: 'ایمیل یا رمز عبور اشتباه است', message: 'ایمیل یا رمز عبور اشتباه است',
error: 'INVALID_CREDENTIALS', error: 'INVALID_CREDENTIALS',

View File

@ -135,7 +135,7 @@ export class OrdersService {
const trackingNumber = this.generateTrackingNumber(); const trackingNumber = this.generateTrackingNumber();
// Deduct user wallet balance and create order atomically // Deduct user wallet balance if payment method is wallet
if (createOrderDto.paymentMethod === 'wallet' && userId) { if (createOrderDto.paymentMethod === 'wallet' && userId) {
const user = await this.prisma.user.findUnique({ where: { id: userId } }); const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
@ -147,52 +147,20 @@ export class OrdersService {
'موجودی کیف پول برای پرداخت این سفارش کافی نیست', 'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
); );
} }
await this.prisma.user.update({
return this.prisma.$transaction(async (tx) => { where: { id: userId },
await tx.user.update({ data: {
where: { id: userId }, walletBalance: { decrement: finalAmount },
data: { },
walletBalance: { decrement: finalAmount }, });
}, await this.prisma.walletTransaction.create({
}); data: {
userId,
await tx.walletTransaction.create({ amount: finalAmount,
data: { type: 'withdrawal',
userId, status: 'completed',
amount: finalAmount, description: `پرداخت سفارش ${trackingNumber}`,
type: 'withdrawal', },
status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`,
},
});
if (charityAmount > 0) {
await tx.user.update({
where: { id: userId },
data: { charityDonationTotal: { increment: charityAmount } },
});
}
return tx.order.create({
data: {
userId,
couponId,
totalAmount: finalAmount,
charityDonation: charityAmount,
isRefill: Boolean(createOrderDto.isRefill),
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
trackingNumber,
status: 'processing',
orderItems: {
create: orderItems,
},
} as any,
include: {
orderItems: {
include: { product: true },
},
},
});
}); });
} }
@ -315,31 +283,4 @@ export class OrdersService {
} }
return order; return order;
} }
async checkAndSendRefillReminders() {
const fiftyEightDaysAgo = new Date(Date.now() - 58 * 24 * 60 * 60 * 1000);
const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000);
const dueRefillOrders = await this.prisma.order.findMany({
where: {
isRefill: true,
createdAt: {
gte: fiftyNineDaysAgo,
lte: fiftyEightDaysAgo,
},
},
include: { user: true },
});
for (const order of dueRefillOrders) {
if (order.user?.mobile) {
await this.smsService
.sendSms(
order.user.mobile,
`کاربر گرامی، مکمل درمانی پت شما در حال اتمام است. جهت شارژ مجدد و استفاده از ۵٪ تخفیف دوره جدید به لینک زیر مراجعه کنید:\nhttps://canina-iran.com/shop`,
)
.catch(() => {});
}
}
}
} }

View File

@ -31,66 +31,6 @@ export class PetsService {
}); });
} }
async findAllAdmin(filters: PaginationDto) {
const {
search,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = {};
if (search) {
whereClause.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ breed: { contains: search, mode: 'insensitive' } },
{ user: { firstName: { contains: search, mode: 'insensitive' } } },
{ user: { lastName: { contains: search, mode: 'insensitive' } } },
{ user: { mobile: { contains: search } } },
];
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
email: true,
},
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where: whereClause }),
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
},
};
}
async findAllByUser(userId: string, filters: PaginationDto) { async findAllByUser(userId: string, filters: PaginationDto) {
const { const {
search, search,
@ -116,7 +56,6 @@ export class PetsService {
skip, skip,
take: limit, take: limit,
orderBy: { [sortBy]: sortOrder }, orderBy: { [sortBy]: sortOrder },
include: { medicalConditions: true, reminders: true, healthLogs: true },
}), }),
this.prisma.pet.count({ where: whereClause }), this.prisma.pet.count({ where: whereClause }),
]); ]);

View File

@ -25,24 +25,4 @@ export class GetProductsDto extends PaginationDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
requiresRx?: string; requiresRx?: string;
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
@IsOptional()
@IsString()
minPrice?: string;
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
@IsOptional()
@IsString()
maxPrice?: string;
@ApiPropertyOptional({ description: 'فیلد مرتب‌سازی', enum: ['priceValue', 'createdAt', 'nameFa'] })
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ description: 'جهت مرتب‌سازی', enum: ['asc', 'desc'] })
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc';
} }

View File

@ -13,67 +13,65 @@ export class ProductsService {
search, search,
symptom, symptom,
requiresRx, requiresRx,
minPrice,
maxPrice,
page = 1, page = 1,
limit = 10, limit = 10,
sortBy = 'createdAt', sortBy = 'createdAt',
sortOrder = 'desc', sortOrder = 'desc',
} = filters; } = filters;
const andConditions: any[] = []; const whereClause: any = {};
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') { if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
andConditions.push({ requiresRx: requiresRx === 'true' || requiresRx === '1' }); whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
} }
if (category) { if (category) {
andConditions.push({ categorySlug: category }); whereClause.categorySlug = category;
} }
if (petType && petType !== 'all') { if (petType && petType !== 'all') {
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } }); whereClause.suitableFor = { in: [petType, 'هر دو'] };
} }
// Filter by a specific symptom (from URL param ?symptom=...)
if (symptom) { if (symptom) {
andConditions.push({ whereClause.symptoms = {
symptoms: { some: {
some: { symptom: { contains: symptom, mode: 'insensitive' },
symptom: { contains: symptom, mode: 'insensitive' },
},
}, },
}); };
}
if (minPrice) {
andConditions.push({ priceValue: { gte: Number(minPrice) } });
}
if (maxPrice) {
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
} }
if (search) { if (search) {
andConditions.push({ // If symptom filter is already applied, extend via AND to also search names/desc
OR: [ // If not, use OR across names, description AND symptoms
{ artNo: { contains: search, mode: 'insensitive' } }, const searchConditions = [
{ barcode: { contains: search, mode: 'insensitive' } }, { artNo: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } }, { barcode: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } }, { nameFa: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }, { nameEn: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } },
{ { shortDescription: { contains: search, mode: 'insensitive' } },
symptoms: { {
some: { symptoms: {
symptom: { contains: search, mode: 'insensitive' }, some: {
}, symptom: { contains: search, mode: 'insensitive' },
}, },
}, },
], },
}); ];
}
const whereClause: any = andConditions.length > 0 ? { AND: andConditions } : {}; if (symptom) {
// Already have a symptom filter; combine with AND
whereClause.AND = [
{ symptoms: whereClause.symptoms },
{ OR: searchConditions.filter((c) => !('symptoms' in c)) },
];
delete whereClause.symptoms;
} else {
whereClause.OR = searchConditions;
}
}
const skip = (page - 1) * limit; const skip = (page - 1) * limit;

View File

@ -194,28 +194,3 @@
- [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعال‌سازی سبد خرید و فاکتور، تبدیل به پیش‌خرید). - [x] **TASK-25.12 [Flexible Catalog-Only Mode Settings]:** توسعه تنظیمات چندحالته «حالت کاتالوگ» (نمایش/عدم نمایش قیمت، فعال/غیرفعال‌سازی سبد خرید و فاکتور، تبدیل به پیش‌خرید).
- [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیش‌خرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن. - [x] **TASK-25.13 [Product Pre-order System]:** ایجاد امکان پیش‌خرید کالا در پنل ادمین (رایگان یا با بیعانه) و ثبت نهایی بعد از موجود شدن.
- [x] **TASK-25.14 [Stock Alert Queue & SMS Notification]:** ایجاد قابلیت «موجود شد اطلاع بده» برای کالاهای ناموجود، صف خودکار اطلاع‌رسانی و ارسال پیامک به محض شارژ موجودی. - [x] **TASK-25.14 [Stock Alert Queue & SMS Notification]:** ایجاد قابلیت «موجود شد اطلاع بده» برای کالاهای ناموجود، صف خودکار اطلاع‌رسانی و ارسال پیامک به محض شارژ موجودی.
---
## 📍 بخش ۲۶: اصلاحات معماری، امنیتی و بهینه‌سازی سیستم (Batch Tasks 26.1 - 26.20)
- [ ] **TASK-26.1 [NestJS Config Module Validation]:** افزودن `@nestjs/config` به بک‌اند و اعتبارسنجی متغیرهای محیطی در زمان Boot سرور.
- [ ] **TASK-26.2 [Unit & E2E Financial Test Coverage]:** تکمیل پوشش تست‌های مالی کیف‌پول و ثبت سفارشات در بک‌اند.
- [ ] **TASK-26.3 [Prisma Enums Refactoring]:** جایگزینی رشته‌های VarChar با Prisma Enums برای مناسب پت، وضعیت سفارش و نقش‌ها.
- [x] **TASK-26.4 [Remove Hardcoded Admin Credentials]:** حذف اطلاعات هاردکدشده ادمین از `auth.service.ts` و انتقال به دیتابیس با پسورد هش‌شده.
- [x] **TASK-26.5 [Remove OTP Console Log]:** حذف لاگ شدن کد OTP در کنسول سرور جهت جلوگیری از افشای امنیتی.
- [ ] **TASK-26.6 [JWT Refresh Token Implementation]:** پیاده‌سازی مکانیزم کامل Refresh Token برای تمدید خودکار نشست کاربر.
- [ ] **TASK-26.7 [SSR & HttpOnly Cookie Auth]:** ذخیره توکن‌ها درون HttpOnly Cookie جهت همگام‌سازی کامل Server & Client Components در Next.js.
- [x] **TASK-26.8 [Logout State Cleanup]:** پاکسازی کامل کاتالوگ، سبد خرید و تمام کش‌های کاربر هنگام خروج (Logout Cleanup).
- [x] **TASK-26.9 [Admin Token Validation Guard]:** اعتبارسنجی واقعی توکن ادمین در `ProtectedRoute.tsx` از طریق فراخوانی API پروفایل در هنگام لود پنل.
- [x] **TASK-26.10 [Fix Pagination & Search Query Overwrite]:** اصلاح منطق `whereClause` در `products.service.ts` جهت جلوگیری از اوررایت شدن فیلترها.
- [x] **TASK-26.11 [Price Range & Sorting Filters]:** افزودن فیلتر محدوده قیمت (`minPrice`, `maxPrice`) و مرتب‌سازی (`sort`) به DTO و اندپویینت‌های محصولات.
- [ ] **TASK-26.12 [Sync Fallback IDs with Database]:** همگام‌سازی شناسه محصولات استاتیک فرانت‌اند با دیتابیس واقعی جهت جلوگیری از 404 در سفارش.
- [ ] **TASK-26.13 [Admin Products Table Pagination]:** پیاده‌سازی پجینیشن سرورساید کامل در جدول مدیریت محصولات پنل ادمین.
- [x] **TASK-26.14 [Admin Wallet Adjust Endpoint]:** ایجاد اندپویینت `POST /api/users/:id/wallet-adjust` و فرم شارژ/کسر مستقیم کیف‌پول در پنل ادمین.
- [x] **TASK-26.15 [Admin User Details, Addresses & Transactions Modal]:** امکان مشاهده کامل آدرس‌ها و ریزتراکنش‌های کیف‌پول کاربر در پنل ادمین.
- [x] **TASK-26.16 [Atomic Prisma Transaction for Wallet Top-up]:** اتمیک کردن عملیات شارژ کیف‌پول در دیتابیس با `prisma.$transaction`.
- [x] **TASK-26.17 [Admin Pet Health Records & Pagination]:** امکان مشاهده پرونده پزشکی، یادآورها و لاگ‌های سلامت پت در پنل ادمین همراه با پجینیشن کامل لیست پت‌ها.
- [ ] **TASK-26.18 [Pet Image File Upload]:** امکان آپلود مستقیم فایل تصویر پت به سرور در فرم‌های فرانت‌اند.
- [x] **TASK-26.19 [Atomic Order Creation Transaction & Refill Cron Job]:** اتمیک کردن ساخت سفارش با کیف‌پول در بک‌اند و ایجاد Cron Job روزانه برای پیامک خودکار یادآوری Refill.
- [x] **TASK-26.20 [Reports Excel Export, Dashboard Live Chart & Video View Counter]:** افزودن قابلیت دانلود خروجی اکسل در صفحه گزارشات، اتصال نمودار داشبورد به دیتای واقعی و ارسال درخواست PATCH افزایش بازدید ویدیوها.

View File

@ -1,43 +1,11 @@
import { useState, useEffect } from 'react';
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate, Outlet } from 'react-router-dom';
import api from '../services/api';
export default function ProtectedRoute() { export default function ProtectedRoute() {
const token = localStorage.getItem('adminToken'); const token = localStorage.getItem('adminToken');
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(true);
useEffect(() => { if (!token) {
if (!token) {
setIsValid(false);
setIsValidating(false);
return;
}
api.get('/users/profile')
.then(() => {
setIsValid(true);
})
.catch(() => {
localStorage.removeItem('adminToken');
setIsValid(false);
})
.finally(() => {
setIsValidating(false);
});
}, [token]);
if (!token || !isValid) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return <Outlet />; return <Outlet />;
} }

View File

@ -94,30 +94,7 @@ export default function Reports() {
</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 className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm">
onClick={() => {
if (!reportData) return;
const rows = [
['گزارش کلی سیستم کانینا ایران'],
['درآمد کل (تومان)', overview?.totalRevenue || 0],
['تعداد سفارشات', overview?.totalOrders || 0],
['تعداد کاربران', overview?.totalUsers || 0],
['میانگین ارزش سفارش', overview?.avgOrderValue || 0],
[],
['محصولات پرفروش', 'تعداد فروش'],
...(bestSellers || []).map(b => [b.name, b.quantity]),
];
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + rows.map(e => e.join(',')).join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', `Canina_Sales_Report_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="bg-white border-2 border-purple-200 text-purple-700 hover:bg-purple-50 px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-sm"
>
<Download className="w-5 h-5" /> <Download className="w-5 h-5" />
خروجی اکسل (Excel) خروجی اکسل (Excel)
</button> </button>

View File

@ -118,10 +118,7 @@ export default function VideosPage() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }} transition={{ delay: idx * 0.1 }}
className="group cursor-pointer" className="group cursor-pointer"
onClick={() => { onClick={() => setSelectedVideo(video)}
setSelectedVideo(video);
videoService.incrementViews(video.id);
}}
> >
<div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5"> <div className="relative aspect-video rounded-[2.5rem] overflow-hidden mb-6 shadow-2xl border border-white/5">
<SafeImage <SafeImage

View File

@ -31,13 +31,5 @@ export const videoService = {
console.error('Failed to fetch video details:', error); console.error('Failed to fetch video details:', error);
return null; return null;
} }
},
incrementViews: async (id: string) => {
try {
await api.patch(`/videos/${id}/view`);
} catch (error) {
console.warn('Failed to increment video views:', error);
}
} }
}; };

View File

@ -164,9 +164,7 @@ export const useUserStore = create<UserStore>()(
logout: () => { logout: () => {
authService.logout(); authService.logout();
try { usePetStore.getState().reset(); } catch {} try { usePetStore.getState().reset(); } catch {}
try { useCartStore.getState().clearCart(); } catch {}
try { localStorage.removeItem("canina-pets"); } catch {} try { localStorage.removeItem("canina-pets"); } catch {}
try { localStorage.removeItem("canina-cart-storage"); } catch {}
set({ set({
role: "User_Guest", role: "User_Guest",
isLoggedIn: false, isLoggedIn: false,