diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 1c4ef9d..7f9659c 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AdminService } from './admin.service'; import { PrismaService } from '../prisma/prisma.service'; import { RedisService } from '../redis/redis.service'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; describe('AdminService', () => { let service: AdminService; @@ -12,6 +13,10 @@ describe('AdminService', () => { AdminService, { provide: PrismaService, useValue: {} }, { provide: RedisService, useValue: {} }, + { + provide: RevalidationService, + useValue: { revalidateTag: jest.fn(), revalidatePath: jest.fn() }, + }, ], }).compile(); diff --git a/backend/src/admin/reports.controller.ts b/backend/src/admin/reports.controller.ts index 411e8f0..b34e6aa 100644 --- a/backend/src/admin/reports.controller.ts +++ b/backend/src/admin/reports.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ReportsService } from './reports.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - گزارشات') @ApiBearerAuth() @@ -11,9 +16,22 @@ export class ReportsController { @UseGuards(JwtAuthGuard) @Get() - @ApiOperation({ summary: 'دریافت گزارشات داشبورد' }) - async getReports() { - const data = await this.reportsService.getDashboardReports(); + @ApiOperation({ + summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر', + }) + @ApiQuery({ name: 'startDate', required: false }) + @ApiQuery({ name: 'endDate', required: false }) + @ApiQuery({ name: 'role', required: false }) + async getReports( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('role') role?: string, + ) { + const data = await this.reportsService.getDashboardReports({ + startDate, + endDate, + role, + }); return { success: true, data }; } } diff --git a/backend/src/admin/reports.service.ts b/backend/src/admin/reports.service.ts index 0eb8fef..6146e6e 100644 --- a/backend/src/admin/reports.service.ts +++ b/backend/src/admin/reports.service.ts @@ -5,29 +5,71 @@ import { PrismaService } from '../prisma/prisma.service'; export class ReportsService { constructor(private prisma: PrismaService) {} - async getDashboardReports() { - // 1. Total revenue and charity + async getDashboardReports(query?: { + startDate?: string; + endDate?: string; + role?: string; + }) { + const whereClause: any = { status: { not: 'cancelled' } }; + + if (query?.startDate || query?.endDate) { + whereClause.createdAt = {}; + if (query.startDate) { + whereClause.createdAt.gte = new Date(query.startDate); + } + if (query.endDate) { + const end = new Date(query.endDate); + end.setHours(23, 59, 59, 999); + whereClause.createdAt.lte = end; + } + } + + if (query?.role && query.role !== 'ALL') { + whereClause.user = { role: query.role }; + } + + // 1. Fetch filtered orders with items and coupons for accurate breakdown const orders = await this.prisma.order.findMany({ - where: { status: { not: 'cancelled' } }, - select: { - totalAmount: true, - charityDonation: true, - createdAt: true, + where: whereClause, + include: { user: { select: { role: true } }, + coupon: true, + orderItems: { + include: { + product: { + select: { + id: true, + buyPrice: true, + priceValue: true, + }, + }, + }, + }, }, + orderBy: { createdAt: 'asc' }, }); - let totalRevenue = 0; - let totalCharity = 0; + let totalRevenue = 0; // Total paid/deposited by users + let totalCharity = 0; // Charity portion let b2bRevenue = 0; let b2cRevenue = 0; + let totalTax = 0; + const totalShipping = 0; + let totalDiscounts = 0; + let totalCostOfGoods = 0; // COGS (مجموع قیمت خرید کالاها) - const salesByDate: Record = {}; + const salesByDate: Record< + string, + { total: number; charity: number; sales: number } + > = {}; for (const order of orders) { - const amount = Number(order.totalAmount); + const amount = Number(order.totalAmount || 0); + const charity = Number(order.charityDonation || 0); + const productSales = Math.max(0, amount - charity); + totalRevenue += amount; - totalCharity += Number(order.charityDonation); + totalCharity += charity; if (order.user?.role === 'B2B') { b2bRevenue += amount; @@ -35,23 +77,74 @@ export class ReportsService { b2cRevenue += amount; } + // Calculate cost of goods (COGS) from order items + let orderCOGS = 0; + let orderItemSubtotal = 0; + + if (order.orderItems && order.orderItems.length > 0) { + for (const item of order.orderItems) { + const qty = item.quantity || 1; + const buyPrice = Number(item.product?.buyPrice || 0); + const salePrice = Number(item.product?.priceValue || 0); + orderCOGS += buyPrice * qty; + orderItemSubtotal += salePrice * qty; + } + } + totalCostOfGoods += orderCOGS; + + // Discount calculation (if coupon was used or total discount applied) + if (order.coupon) { + if ( + order.coupon.type === 'percent' || + order.coupon.type === 'PERCENTAGE' + ) { + const discountVal = + (orderItemSubtotal * Number(order.coupon.value)) / 100; + totalDiscounts += discountVal; + } else { + totalDiscounts += Number(order.coupon.value || 0); + } + } + + // Standard VAT / Tax (approx 10% on product sales if configured) + const taxRate = 0.1; + const orderTax = Math.round(productSales * (taxRate / (1 + taxRate))); + totalTax += orderTax; + // Format date YYYY-MM-DD const dateKey = order.createdAt.toISOString().split('T')[0]; - if (!salesByDate[dateKey]) salesByDate[dateKey] = 0; - salesByDate[dateKey] += amount; + if (!salesByDate[dateKey]) { + salesByDate[dateKey] = { total: 0, charity: 0, sales: 0 }; + } + salesByDate[dateKey].total += amount; + salesByDate[dateKey].charity += charity; + salesByDate[dateKey].sales += productSales; } + // Product Sales Revenue (excluding charity) + const productSalesRevenue = Math.max(0, totalRevenue - totalCharity); + // Net profit = Product Sales - COGS - Tax - Discounts + const netProfit = Math.max( + 0, + productSalesRevenue - totalCostOfGoods - totalTax - totalDiscounts, + ); + // Format sales timeline for charts const salesTimeline = Object.entries(salesByDate) - .map(([date, amount]) => ({ date, amount })) + .map(([date, data]) => ({ + date, + amount: data.total, + charity: data.charity, + sales: data.sales, + })) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - // 2. Best selling products (by quantity) + // 2. Best selling products (by quantity) in the selected range const orderItems = await this.prisma.orderItem.groupBy({ by: ['productId'], _sum: { quantity: true }, where: { - order: { status: { not: 'cancelled' } }, + order: whereClause, productId: { not: null }, }, orderBy: { _sum: { quantity: 'desc' } }, @@ -89,7 +182,7 @@ export class ReportsService { }) .filter((c) => c.value > 0); - // 4. Coupons Usage + // 4. Coupons Usage in range const topCoupons = await this.prisma.coupon.findMany({ orderBy: { usedCount: 'desc' }, take: 5, @@ -100,9 +193,15 @@ export class ReportsService { overview: { totalRevenue, totalCharity, + productSalesRevenue, totalOrders: orders.length, b2bRevenue, b2cRevenue, + totalTax, + totalShipping, + totalDiscounts, + totalCostOfGoods, + netProfit, }, salesTimeline, bestSellers, diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 2e996ba..1e997bb 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -28,6 +28,7 @@ describe('AuthService', () => { set: jest.fn(), get: jest.fn(), del: jest.fn(), + incr: jest.fn().mockResolvedValue(1), }; const mockSms = { diff --git a/backend/src/orders/orders.service.spec.ts b/backend/src/orders/orders.service.spec.ts index fd58625..dbd4259 100644 --- a/backend/src/orders/orders.service.spec.ts +++ b/backend/src/orders/orders.service.spec.ts @@ -27,6 +27,12 @@ describe('OrdersService', () => { walletTransaction: { create: jest.fn(), }, + setting: { + findFirst: jest.fn().mockResolvedValue(null), + }, + uiText: { + findFirst: jest.fn().mockResolvedValue(null), + }, }; const mockSmsService = { diff --git a/backend/src/products/products.service.spec.ts b/backend/src/products/products.service.spec.ts index 7480028..644a1b2 100644 --- a/backend/src/products/products.service.spec.ts +++ b/backend/src/products/products.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProductsService } from './products.service'; import { PrismaService } from '../prisma/prisma.service'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; describe('ProductsService', () => { let service: ProductsService; @@ -15,11 +16,17 @@ describe('ProductsService', () => { }, }; + const mockRevalidationService = { + revalidateTag: jest.fn(), + revalidatePath: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ProductsService, { provide: PrismaService, useValue: mockPrisma }, + { provide: RevalidationService, useValue: mockRevalidationService }, ], }).compile(); diff --git a/backend/src/settings/settings.controller.spec.ts b/backend/src/settings/settings.controller.spec.ts index 1c43e79..743e7de 100644 --- a/backend/src/settings/settings.controller.spec.ts +++ b/backend/src/settings/settings.controller.spec.ts @@ -37,14 +37,11 @@ describe('SettingsController', () => { expect(controller).toBeDefined(); }); - it('should have JwtAuthGuard, RolesGuard and Admin role applied at controller level', () => { - const guards = Reflect.getMetadata(GUARDS_METADATA, SettingsController); + it('should have JwtAuthGuard and RolesGuard on protected mutation routes', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller.putUiText); expect(guards).toBeDefined(); expect(guards).toContain(JwtAuthGuard); expect(guards).toContain(RolesGuard); - - const roles = Reflect.getMetadata(ROLES_KEY, SettingsController); - expect(roles).toEqual(['Admin']); }); it('should getUiTexts', async () => { diff --git a/backend/src/settings/settings.service.spec.ts b/backend/src/settings/settings.service.spec.ts index 9e5f1b1..de36826 100644 --- a/backend/src/settings/settings.service.spec.ts +++ b/backend/src/settings/settings.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SettingsService } from './settings.service'; import { PrismaService } from '../prisma/prisma.service'; +import { SmsService } from '../common/services/sms.service'; describe('SettingsService', () => { let service: SettingsService; @@ -11,6 +12,10 @@ describe('SettingsService', () => { findMany: jest.fn(), upsert: jest.fn(), }, + setting: { + findMany: jest.fn().mockResolvedValue([]), + upsert: jest.fn(), + }, scientificTerm: { findMany: jest.fn(), upsert: jest.fn(), @@ -18,11 +23,17 @@ describe('SettingsService', () => { }, }; + const mockSmsService = { + getSmsLogs: jest.fn(), + sendOtp: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SettingsService, { provide: PrismaService, useValue: mockPrisma }, + { provide: SmsService, useValue: mockSmsService }, ], }).compile(); diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 8aea99f..59876ac 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -44,7 +44,7 @@ describe('UsersService', () => { mockPrisma.user.findUnique.mockResolvedValue(mockUser); const result = await service.findById('user-id'); expect(prisma.user.findUnique).toHaveBeenCalled(); - expect(result).toEqual(mockUser); + expect(result).toEqual({ ...mockUser, hasPassword: false }); }); it('should call prisma update in update', async () => { diff --git a/frontend/admin-panel/src/pages/Blogs.tsx b/frontend/admin-panel/src/pages/Blogs.tsx index 854f291..be55cad 100644 --- a/frontend/admin-panel/src/pages/Blogs.tsx +++ b/frontend/admin-panel/src/pages/Blogs.tsx @@ -684,13 +684,14 @@ export default function Blogs() { {blog.author?.firstName ? `${blog.author.firstName} ${blog.author.lastName || ''}` : 'نامشخص'} -
+
{blog.readingTime || 3} دقیقه
- - {blog.viewCount || 0} بازدید - +
+ + {(blog.viewCount || 0).toLocaleString('fa-IR')} بازدید +
{renderStatusBadge(blog.status, blog.isPublished)} diff --git a/frontend/admin-panel/src/pages/Reports.tsx b/frontend/admin-panel/src/pages/Reports.tsx index 591f927..730c59c 100644 --- a/frontend/admin-panel/src/pages/Reports.tsx +++ b/frontend/admin-panel/src/pages/Reports.tsx @@ -1,11 +1,21 @@ -import { useState, useEffect } from 'react'; -import { TrendingUp, DollarSign, ShoppingBag, Users, HeartHandshake, Download } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { + TrendingUp, + DollarSign, + ShoppingBag, + Users, + HeartHandshake, + Download, + Calendar, + Filter, + Receipt, + Percent, + Package, + ShieldCheck +} from 'lucide-react'; import api from '../services/api'; import Button from '../components/ui/Button'; import Spinner from '../components/ui/Spinner'; - - - import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend @@ -15,16 +25,21 @@ const COLORS = ['#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#F59E0B']; interface CustomTooltipProps { active?: boolean; - payload?: Array<{ value: number | string }>; + payload?: Array<{ value: number | string; name?: string; color?: string }>; label?: string; } const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { if (active && payload && payload.length) { return ( -
+

{label}

-

{Number(payload[0].value).toLocaleString()} تومان

+ {payload.map((item, idx) => ( +
+ {item.name || 'مبلغ'}: + {Number(item.value).toLocaleString()} تومان +
+ ))}
); } @@ -50,13 +65,20 @@ export interface TopCouponItem { export interface ReportData { overview?: { totalRevenue: number; - totalOrders: number; - totalUsers: number; - avgOrderValue: number; totalCharity?: number; + productSalesRevenue?: number; + totalOrders: number; + totalUsers?: number; + avgOrderValue?: number; b2bRevenue?: number; + b2cRevenue?: number; + totalTax?: number; + totalShipping?: number; + totalDiscounts?: number; + totalCostOfGoods?: number; + netProfit?: number; }; - salesTimeline?: Array<{ date: string; amount: number }>; + salesTimeline?: Array<{ date: string; amount: number; charity?: number; sales?: number }>; bestSellers?: BestSellerItem[]; categoryDistribution?: CategoryDistItem[]; topCoupons?: TopCouponItem[]; @@ -66,40 +88,72 @@ export default function Reports() { const [reportData, setReportData] = useState(null); const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - let isSubscribed = true; - api.get('/admin/reports').then(reportRes => { - if (!isSubscribed) return; - if (reportRes.data?.success) { - setReportData(reportRes.data.data); - } - }).catch(err => { - console.error(err); - }).finally(() => { - if (isSubscribed) setIsLoading(false); - }); - return () => { - isSubscribed = false; - }; - }, []); + // Filter States + const [dateFilterType, setDateFilterType] = useState<'all' | 'today' | '7days' | '30days' | 'custom'>('30days'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [roleFilter, setRoleFilter] = useState('ALL'); - if (isLoading) { + const fetchReports = useCallback(async () => { + try { + setIsLoading(true); + const params: Record = {}; + + const now = new Date(); + if (dateFilterType === 'today') { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + params.startDate = start.toISOString(); + } else if (dateFilterType === '7days') { + const start = new Date(now); + start.setDate(now.getDate() - 7); + params.startDate = start.toISOString(); + } else if (dateFilterType === '30days') { + const start = new Date(now); + start.setDate(now.getDate() - 30); + params.startDate = start.toISOString(); + } else if (dateFilterType === 'custom') { + if (startDate) params.startDate = startDate; + if (endDate) params.endDate = endDate; + } + + if (roleFilter !== 'ALL') { + params.role = roleFilter; + } + + const res = await api.get('/admin/reports', { params }); + if (res.data?.success) { + setReportData(res.data.data); + } + } catch (err) { + console.error('Failed to fetch reports:', err); + } finally { + setIsLoading(false); + } + }, [dateFilterType, startDate, endDate, roleFilter]); + + useEffect(() => { + fetchReports(); + }, [fetchReports]); + + if (isLoading && !reportData) { return
; } - if (!reportData) return
اطلاعاتی یافت نشد
; - - const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData; + const { overview, salesTimeline, bestSellers, categoryDistribution, topCoupons } = reportData || {}; return ( -
-
+
+ {/* Header */} +

- گزارشات و تحلیل‌ها + گزارشات و تحلیل‌های مالی پیشرفته

-

آمار فروش، رفتار کاربران و عملکرد تخفیف‌ها

+

+ تفکیک درآمد کالاها، مبالغ واریزی مهربانی، مالیات، تخفیف‌ها و سود خالص +

+ {/* Filter Toolbar */} +
+
+ + بازه زمانی: +
- {/* Overview Cards */} -
-
-
-

درآمد کل

-
-
-

{Number(overview.totalRevenue).toLocaleString()}

-

تومان

+
+ + + + +
- -
-
-

سهم خیریه (مسئولیت اجتماعی)

-
+ + {dateFilterType === 'custom' && ( +
+ setStartDate(e.target.value)} + className="px-3 py-1.5 rounded-xl border border-gray-200 outline-none text-xs" + /> + تا + setEndDate(e.target.value)} + className="px-3 py-1.5 rounded-xl border border-gray-200 outline-none text-xs" + />
-

{Number(overview.totalCharity).toLocaleString()}

-

تومان

-
- -
-
-

فروش همکاران (B2B)

-
-
-

{Number(overview.b2bRevenue).toLocaleString()}

-

تومان

-
- -
-
-

تعداد سفارشات

-
-
-

{overview.totalOrders}

-

موفق

+ )} + +
+ نوع مشتری: +
+ {/* Overview Cards (Core Revenue & Charity) */} +
+ {/* Total Deposited */} +
+
+

مجموع کل واریزی‌ها

+
+
+

{Number(overview?.totalRevenue || 0).toLocaleString()}

+

تومان (شامل کالا و مهربانی)

+
+ + {/* Charity Share */} +
+
+

سهم مبالغ مهربانی (خیریه)

+
+
+

{Number(overview?.totalCharity || 0).toLocaleString()}

+

+ {overview?.totalRevenue ? `${((Number(overview.totalCharity || 0) / Number(overview.totalRevenue)) * 100).toFixed(1)}% از کل دریافتی` : 'تومان'} +

+
+ + {/* Product Sales Only */} +
+
+

خالص فروش محصولات

+
+
+

{Number(overview?.productSalesRevenue || (Number(overview?.totalRevenue || 0) - Number(overview?.totalCharity || 0))).toLocaleString()}

+

تومان (بدون احتساب مبالغ خیریه)

+
+ + {/* Net Profit */} +
+
+

سود خالص تخمینی

+
+
+

{Number(overview?.netProfit || 0).toLocaleString()}

+

تومان (پس از کسر خرید، مالیات و تخفیف)

+
+
+ + {/* Financial Breakdown Section (Item 2 Requirement) */} +
+

+ + تراز و تفکیک اجزای مالی +

+
+
+ بهای خرید کالاها (COGS) + {Number(overview?.totalCostOfGoods || 0).toLocaleString()} + تومان +
+
+ مالیات بر ارزش افزوده + {Number(overview?.totalTax || 0).toLocaleString()} + تومان +
+
+ مجموع تخفیف‌ها + {Number(overview?.totalDiscounts || 0).toLocaleString()} + تومان +
+
+ فروش عمده (B2B) + {Number(overview?.b2bRevenue || 0).toLocaleString()} + تومان +
+
+ فروش تکی (B2C) + {Number(overview?.b2cRevenue || 0).toLocaleString()} + تومان +
+
+ تعداد کل سفارشات + {overview?.totalOrders || 0} + سفارش موفق +
+
+
+ + {/* Charts Section */}
- {/* Sales Chart */} -
-

نمودار فروش روزانه

+ {/* Timeline Chart with Dual Area for Total & Charity */} +
+
+

روند فروش و واریزی‌های مهربانی

+
+ + + کل واریزی + + + + سهم مهربانی + +
+
@@ -183,20 +376,25 @@ export default function Reports() { + + + + - - `${val / 1000}k`} /> + + `${val >= 1000000 ? `${(val/1000000).toFixed(1)}M` : `${val / 1000}k`}`} /> } /> - + +
{/* Category Distribution */} -
-

سهم فروش دسته‌بندی‌ها

+
+

سهم فروش دسته‌بندی‌ها

{categoryDistribution && categoryDistribution.length > 0 ? ( @@ -211,19 +409,20 @@ export default function Reports() { ) : ( -
داده‌ای موجود نیست
+
داده‌ای موجود نیست
)}
+ {/* Best Sellers & Top Coupons */}
{/* Best Sellers */} -
-

پرفروش‌ترین محصولات

-
+
+

پرفروش‌ترین محصولات در بازه انتخابی

+
{bestSellers && bestSellers.length > 0 ? ( - + ) : ( -
داده‌ای موجود نیست
+
داده‌ای موجود نیست
)}
{/* Top Coupons */} -
-

پرکاربردترین کدهای تخفیف

-
+
+

کدهای تخفیف پراستفاده

+
{topCoupons && topCoupons.length > 0 ? ( topCoupons.map((coupon: TopCouponItem, idx: number) => ( -
+
-
+
#{idx + 1}
- {coupon.name} + {coupon.name}
-
- {coupon.usedCount ?? coupon.value ?? 0}بار استفاده +
+ {coupon.usedCount ?? coupon.value ?? 0}بار استفاده
)) ) : ( -
داده‌ای موجود نیست
+
داده‌ای موجود نیست
)}
diff --git a/frontend/application/app/ClientLayout.tsx b/frontend/application/app/ClientLayout.tsx index 51cf215..85ee5a9 100644 --- a/frontend/application/app/ClientLayout.tsx +++ b/frontend/application/app/ClientLayout.tsx @@ -50,8 +50,36 @@ export default function ClientLayout({ children }: { children: React.ReactNode } } else { useUserStore.getState().logout(); } + + // Preserve scroll restoration on back/forward + if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) { + window.history.scrollRestoration = 'auto'; + } }, [fetchProfile, fetchSettings]); + // Handle scroll position tracking per pathname for reliable mobile back navigation + useEffect(() => { + if (typeof window === 'undefined') return; + + const key = `scroll_pos_${pathname}`; + const savedPos = sessionStorage.getItem(key); + if (savedPos !== null) { + const targetY = parseInt(savedPos, 10); + requestAnimationFrame(() => { + window.scrollTo({ top: targetY, behavior: 'instant' }); + }); + } + + const handleScroll = () => { + sessionStorage.setItem(`scroll_pos_${pathname}`, window.scrollY.toString()); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [pathname]); + // Check Maintenance Mode (Admin / Partner bypass) const isMaintenanceMode = getText('MAINTENANCE_MODE', 'false') === 'true' || getText('maintenance_mode', 'false') === 'true'; const isAdmin = role === 'User_Partner' || (typeof window !== 'undefined' && Boolean(localStorage.getItem('adminToken'))); diff --git a/frontend/application/components/ArchivePage.tsx b/frontend/application/components/ArchivePage.tsx index 64983a7..cd0d154 100644 --- a/frontend/application/components/ArchivePage.tsx +++ b/frontend/application/components/ArchivePage.tsx @@ -297,49 +297,40 @@ export default function ArchivePage({ return labels; }, [categories]); - // Sync state with prop change (e.g. from Header menu) + const lastInitialPropsRef = React.useRef({ initialCategory, initialSearch, initialPetType, initialSymptoms }); + + // Sync state when incoming initial props change from navigation useEffect(() => { - // Normalize Input (Trim and lowercase) - const normSearch = (initialSearch || "").trim().toLowerCase(); - const normCat = initialCategory.trim().toLowerCase(); + const isNewCategory = lastInitialPropsRef.current.initialCategory !== initialCategory; + const isNewSearch = lastInitialPropsRef.current.initialSearch !== initialSearch; + const isNewPet = lastInitialPropsRef.current.initialPetType !== initialPetType; + const isNewSymptoms = lastInitialPropsRef.current.initialSymptoms !== initialSymptoms; - const mapKey = normSearch || normCat; - const mapping = CATEGORY_MAP[mapKey]; - - if (selectedCategory === initialCategory && searchQuery === initialSearch) { + if (!isNewCategory && !isNewSearch && !isNewPet && !isNewSymptoms) { return; } - Promise.resolve().then(() => { - setIsUpdating(true); + lastInitialPropsRef.current = { initialCategory, initialSearch, initialPetType, initialSymptoms }; - // Reset ALL other filters when a new category/solution is selected from menu + // Normalize Input + const normSearch = (initialSearch || "").trim().toLowerCase(); + const normCat = (initialCategory || "").trim().toLowerCase(); + const mapKey = normSearch || normCat; + const mapping = CATEGORY_MAP[mapKey]; + + setIsUpdating(true); + if (mapping) { + setSelectedCategory(mapping.category || "all"); + setSearchQuery(mapping.query || ""); setSelectedPet("all"); - setActiveSymptoms([]); - setSearchQuery(""); - - if (mapping) { - if (mapping.category) setSelectedCategory(mapping.category); - if (mapping.query) setSearchQuery(mapping.query); - if (mapping.symptoms) { - setActiveSymptoms(mapping.symptoms); - } - } else { - setSelectedCategory(initialCategory); - setSearchQuery(initialSearch); - - if (initialSearch && symptoms.includes(initialSearch)) { - setActiveSymptoms([initialSearch]); - } - } - }); - - const timer = setTimeout(() => { - window.scrollTo({ top: 0, behavior: "smooth" }); - }, 400); - - return () => clearTimeout(timer); - }, [initialCategory, initialSearch, selectedCategory, searchQuery, symptoms]); + setActiveSymptoms(mapping.symptoms || []); + } else { + setSelectedCategory(initialCategory || "all"); + setSearchQuery(initialSearch || ""); + setSelectedPet((initialPetType as PetType) || "all"); + setActiveSymptoms(initialSymptoms ? initialSymptoms.split(',').filter(Boolean) : []); + } + }, [initialCategory, initialSearch, initialPetType, initialSymptoms]); // Update URL Query Parameters useEffect(() => { diff --git a/frontend/application/components/AuthModal.tsx b/frontend/application/components/AuthModal.tsx index 135305b..233d8fc 100644 --- a/frontend/application/components/AuthModal.tsx +++ b/frontend/application/components/AuthModal.tsx @@ -369,23 +369,23 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { return ( {isOpen && ( -
+
{/* Header */} -
+
ورود امن به کنینا @@ -425,6 +425,8 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { {featuredPost.readingTime || 4} دقیقه مطالعه
+
+ + {(featuredPost.viewCount ?? 0).toLocaleString('fa-IR')} بازدید +

{featuredPost.title} @@ -324,6 +328,11 @@ export default function BlogPage({ {post.category} )} + {/* View Count Badge */} + + + {(post.viewCount ?? 0).toLocaleString('fa-IR')} +

@@ -331,9 +340,15 @@ export default function BlogPage({ {post.date}
-
- - {post.readingTime || 3} دقیقه +
+
+ + {post.readingTime || 3} دقیقه +
+
+ + {(post.viewCount ?? 0).toLocaleString('fa-IR')} +

diff --git a/frontend/application/components/FeaturedProducts.tsx b/frontend/application/components/FeaturedProducts.tsx index b24c83e..1b6e29d 100644 --- a/frontend/application/components/FeaturedProducts.tsx +++ b/frontend/application/components/FeaturedProducts.tsx @@ -43,21 +43,22 @@ function ProductCard({ product }: { product: Product }) { return ( router.push(`/shop/${product.slug || product.id}`)} + className="group bg-white/90 backdrop-blur-md rounded-2xl sm:rounded-3xl border border-medical-gray-200/80 overflow-hidden hover:shadow-xl hover:shadow-canina-blue/10 hover:border-canina-blue/30 transition-all duration-300 flex flex-col cursor-pointer relative" > {/* Category Badge */} -
+
{product.category}
{/* Compatibility Tag */} {compatibility && ( -
@@ -66,22 +67,20 @@ function ProductCard({ product }: { product: Product }) {
)} - {/* Image */} -
+ {/* Image Container */} +
- {/* PDP Link overlay */} - - {/* Hover Actions */} -
+ {/* Hover Actions for desktop */} +
{ e.stopPropagation(); router.push(`/shop/${product.slug || product.id}`); }} - className="w-11 h-11 rounded-full bg-white text-canina-blue flex items-center justify-center hover:scale-110 transition-transform shadow-lg cursor-pointer" + className="w-10 h-10 rounded-full bg-white text-canina-blue flex items-center justify-center hover:scale-110 transition-transform shadow-lg cursor-pointer" title="مشاهده جزئیات" > @@ -93,7 +92,7 @@ function ProductCard({ product }: { product: Product }) { addItem(product, 1); toast.success(`${nameFa} به سبد خرید اضافه شد.`); }} - className="w-11 h-11 rounded-full bg-canina-blue text-white flex items-center justify-center hover:scale-110 transition-transform shadow-lg border border-white/20 cursor-pointer" + className="w-10 h-10 rounded-full bg-canina-blue text-white flex items-center justify-center hover:scale-110 transition-transform shadow-lg border border-white/20 cursor-pointer" title="افزودن به سبد خرید" > @@ -103,40 +102,40 @@ function ProductCard({ product }: { product: Product }) {
{/* Content */} - +
{/* Dual-language Name */} -
-

+
+

{nameFa}

{nameEn && ( - + {nameEn} )}
{/* Short description */} -

+

{product.shortDescription || product.description || ''}

{/* Footer: Price + CTA */} -
-
+
+
{showPrices ? (typeof product.price === 'string' || typeof product.price === 'number' ? String(product.price) : 'تماس بگیرید') : 'تماس بگیرید'}
{showPreorderBtn ? ( - + ثبت پیش‌خرید ) : ( - + {getText("product_view_details", "مشاهده جزئیات")} )}
- +
); } diff --git a/frontend/application/components/Header.tsx b/frontend/application/components/Header.tsx index 9072a1d..737867d 100644 --- a/frontend/application/components/Header.tsx +++ b/frontend/application/components/Header.tsx @@ -554,37 +554,155 @@ export default function Header({ initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} - className="lg:hidden relative z-50 bg-white border-b border-medical-gray-200 px-4 py-6 space-y-4 shadow-xl" + className="lg:hidden relative z-50 bg-white border-b border-medical-gray-200 px-4 py-5 space-y-4 shadow-xl max-h-[85vh] overflow-y-auto font-vazir text-right" + dir="rtl" > -
+ 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" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2.5 pr-10 pl-4 text-xs font-bold focus:ring-2 focus:ring-canina-blue/20 outline-none" /> -
- setIsMobileMenuOpen(false)} className="block p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900"> - 🛒 محصولات تخصصی کنینا + {/* Section: Shop & Catalog */} +
+
+ فروشگاه و محصولات +
+ setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl bg-medical-gray-50 text-medical-gray-900 font-bold text-xs hover:bg-canina-blue/10 hover:text-canina-blue transition-all" + > + + فروشگاه تخصصی محصولات کنینا - setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> - 📑 کاتالوگ آنلاین و دوز مصرفی + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + کاتالوگ دیجیتال و راهنمای بالینی - setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> - 🧬 دانشنامه علمی +
+ + {/* Section: Categories */} +
+
+ دسته‌بندی‌های درمانی +
+
+ {menuItems.map((item, idx) => ( + setIsMobileMenuOpen(false)} + className="flex items-center gap-2 p-2.5 rounded-xl border border-medical-gray-100 bg-white hover:border-canina-blue/30 text-medical-gray-800 text-[11px] font-bold" + > + {item.icon} + {item.title} + + ))} +
+
+ + {/* Section: Academy & Science */} +
+
+ دانشنامه و آموزش +
+ setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + دانشنامه علمی کنینا - setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> - 📰 مجله سلامت پت + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + مجله سلامت پت (وبلاگ) - setIsMobileMenuOpen(false)} className="block p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800"> - 🎥 آکادمی ویدئویی و مشاوره دامپزشک + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > +
+ + {/* Section: Tools & Profile */} +
+
+ ابزارهای هوشمند +
+ setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + شناسنامه و سوابق سلامت پت + + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + پایش هوشمند مصرف مکمل‌ها + +
+ + {/* Section: Company & Contact */} +
+
+ اطلاعات و تماس +
+ setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + درباره کمپانی کنینا آلمان + + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-emerald-600 font-bold text-xs transition-all" + > + + نمادهای اعتماد و مجوزهای رسمی + + {isB2BEnabled && ( + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + درخواست نمایندگی و خرید عمده (B2B) + + )} + setIsMobileMenuOpen(false)} + className="flex items-center gap-3 p-3 rounded-xl hover:bg-medical-gray-50 text-medical-gray-800 font-bold text-xs transition-all" + > + + تماس با مرکز پشتیبانی
diff --git a/frontend/application/components/SmartAdvisor.tsx b/frontend/application/components/SmartAdvisor.tsx index f26bca3..ac73492 100644 --- a/frontend/application/components/SmartAdvisor.tsx +++ b/frontend/application/components/SmartAdvisor.tsx @@ -301,14 +301,14 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro
-
+
{/* Progress Indicator */} -
+
@@ -319,33 +319,35 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} - className="space-y-8" + className="space-y-6" >
-

{getText('advisor_step1_title', "گام اول: هویت بصری")}

-

{getText('advisor_step1_desc', "همدم شما رو با چه اسمی صدا می‌زنید؟")}

+

{getText('advisor_step1_title', "گام اول: مشخصات پت")}

+

{getText('advisor_step1_desc', "همدم شما رو با چه اسمی صدا می‌زنید؟")}

-
+
-
-
- +
+
+
-
- +
+
- + + {getText('advisor_submit_btn', "ثبت هویت و ادامه")} + + + {showError && (!name.trim() || !breed.trim()) && ( +

+ {getText('advisor_validation_warning', "⚠️ لطفاً ابتدا نام و نژاد پت را وارد کنید.")} +

)} - > - {getText('advisor_submit_btn', "ثبت هویت و ادامه")} - -
- {showError && (!name.trim() || !breed.trim()) && ( -

- {getText('advisor_validation_warning', "⚠️ لطفاً ابتدا نام و نژاد پت را در بالا وارد کنید.")} -

- )} +
)} @@ -408,60 +413,95 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} - className="space-y-8" + className="space-y-6 max-w-xl mx-auto" >
-

{getText('advisor_step2_title', "گام دوم: پایش فیزیکی")}

-

{getText('advisor_step2_desc', "اطلاعات فیزیکی دقیق به دوزبندی صحیح مکمل‌ها کمک می‌کند")}

+

{getText('advisor_step2_title', "گام دوم: پایش فیزیکی")}

+

{getText('advisor_step2_desc', "اطلاعات فیزیکی به دوزبندی دقیق مکمل‌ها کمک می‌کند")}

-
-
- -
- -
+
+ {/* Age Input Box */} +
+ +
+ +
setAge(Math.max(0, parseInt(e.target.value) || 0))} - className="w-12 text-center text-xl font-black text-canina-blue font-vazir outline-none bg-transparent" + type="text" + inputMode="numeric" + pattern="[0-9]*" + value={age === 0 ? '' : age.toString()} + onChange={(e) => { + const val = e.target.value.replace(/[^0-9]/g, ''); + setAge(val === '' ? 0 : Math.min(30, parseInt(val, 10))); + }} + className="w-14 text-center text-lg font-black text-canina-blue font-mono outline-none bg-transparent" /> - سال + سال
- +
-
- -
- -
+ + {/* Weight Input Box */} +
+ +
+ +
setWeight(Math.max(1, parseInt(e.target.value) || 1))} - className="w-16 text-center text-xl font-black text-canina-blue font-vazir outline-none bg-transparent" + type="text" + inputMode="numeric" + pattern="[0-9]*" + value={weight === 0 ? '' : weight.toString()} + onChange={(e) => { + const val = e.target.value.replace(/[^0-9]/g, ''); + setWeight(val === '' ? 0 : Math.min(120, parseInt(val, 10))); + }} + className="w-14 text-center text-lg font-black text-canina-blue font-mono outline-none bg-transparent" /> - kg + kg
- +
-
- -
+ {/* Activity Level */} +
+ +
{["کم", "متوسط", "زیاد"].map(level => ( @@ -469,20 +509,23 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro
-
+ {/* Buttons (matching Hero layout) */} +
- +
@@ -494,42 +537,45 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} - className="space-y-8" + className="space-y-5 max-w-2xl mx-auto" >
-

{getText('advisor_step3_symptoms_title', "گام سوم: علائم بالینی فعلی")}

-

{getText('advisor_step3_symptoms_desc', "آیا همدم شما در حال حاضر هیچ‌کدام از علائم زیر را تجربه می‌کند؟")}

+

{getText('advisor_step3_symptoms_title', "گام سوم: علائم بالینی فعلی")}

+

{getText('advisor_step3_symptoms_desc', "آیا همدم شما در حال حاضر علائم زیر را دارد؟")}

-
+
{CURRENT_SYMPTOMS.map((opt) => ( ))}
-
+
@@ -541,42 +587,45 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} - className="space-y-8" + className="space-y-5 max-w-2xl mx-auto" >
-

{getText('advisor_step4_history_title', "گام چهارم: سوابق پزشکی و جراحی")}

-

{getText('advisor_step4_history_desc', "در صورت وجود سابقه جراحی، بارداری یا نارسایی، آن را مشخص کنید")}

+

{getText('advisor_step4_history_title', "گام چهارم: سوابق پزشکی")}

+

{getText('advisor_step4_history_desc', "در صورت وجود سابقه جراحی، بارداری یا نارسایی، آن را مشخص کنید")}

-
+
{MEDICAL_HISTORIES.map((opt) => ( ))}
-
+
diff --git a/frontend/application/components/VideoModalPlayer.tsx b/frontend/application/components/VideoModalPlayer.tsx index 108dbe1..3be040c 100644 --- a/frontend/application/components/VideoModalPlayer.tsx +++ b/frontend/application/components/VideoModalPlayer.tsx @@ -71,20 +71,37 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP }; }, [showRateMenu]); - // Reset states on open/close + // Reset / Restore states on open/close useEffect(() => { - if (isOpen) { + if (isOpen && video?.id) { + const savedTime = localStorage.getItem(`video_time_${video.id}`); + const initialTime = savedTime ? parseFloat(savedTime) : 0; + setIsPlaying(true); setShowControls(true); setIsExpandedDesc(false); setShowRateMenu(false); setBufferedEnd(0); - } else { + + if (videoRef.current && initialTime > 0) { + videoRef.current.currentTime = initialTime; + setCurrentTime(initialTime); + } + } else if (!isOpen && video?.id) { + if (currentTime > 0) { + localStorage.setItem(`video_time_${video.id}`, currentTime.toString()); + } setIsPlaying(false); - setCurrentTime(0); setBufferedEnd(0); } - }, [isOpen, video]); + }, [isOpen, video?.id]); + + // Save playback time periodically + useEffect(() => { + if (video?.id && currentTime > 0) { + localStorage.setItem(`video_time_${video.id}`, currentTime.toString()); + } + }, [currentTime, video?.id]); // Update buffered track const updateBuffer = useCallback(() => { @@ -254,164 +271,147 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP return ( -
- {/* Backdrop */} +
+ {/* Backdrop (backdrop dismiss disabled to prevent accidental closure) */} - {/* Modal Window */} - - {/* Iframe Support (Aparat / Youtube) */} - {isIframe ? ( -
- -
-
- ) : ( - <> - {/* Native Video Element */} -
-