'use client'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { useAuth } from '@/contexts/AuthContext'; import { authLogsApi } from '@/lib/api/authLogs'; import { packagesApi } from '@/lib/api/packages'; import PageHeader from '@/components/admin/PageHeader'; import PersianDatePicker from '@/components/admin/PersianDatePicker'; import { PackageName } from '@/types/package'; import { AuthLog, AuthLogFilters, AuthStats, AUTH_EVENTS, getAuthEventLabel, PaginatedAuthLogs, } from '@/types/auth-log'; import { formatPrice, formatDate, formatTime } from '@/lib/utils'; import { MagnifyingGlassIcon, ArrowPathIcon, DevicePhoneMobileIcon, ArrowRightOnRectangleIcon, CheckBadgeIcon, BanknotesIcon, UserGroupIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; // Default price of one Kavenegar verify SMS, in Toman. Only used to price the // SMS total in the panel — the admin can change it and the choice is remembered. const DEFAULT_SMS_UNIT_COST = 2000; const SMS_COST_STORAGE_KEY = 'approagency:sms_unit_cost'; // Today as the Gregorian YYYY-MM-DD the API and PersianDatePicker exchange. const todayValue = (): string => { const now = new Date(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${now.getFullYear()}-${month}-${day}`; }; // The section opens on today's activity; widen the range with the date pickers. const createDefaultFilters = (): AuthLogFilters => ({ event: '', mobile: '', package_name: '', date_from: todayValue(), date_to: todayValue(), }); const eventBadgeClass = (event: string, success: boolean): string => { if (!success) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; if (event === 'otp_sent' || event === 'mobile_otp_sent') { return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'; } return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; }; export default function AuthAnalyticsPage() { const { token } = useAuth(); const [packages, setPackages] = useState([]); const [filters, setFilters] = useState(createDefaultFilters); const [smsUnitCost, setSmsUnitCost] = useState(DEFAULT_SMS_UNIT_COST); const [initialized, setInitialized] = useState(false); const [stats, setStats] = useState(null); const [logs, setLogs] = useState(null); const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const load = useCallback(async ( targetPage: number, activeFilters: AuthLogFilters, unitCost: number ) => { if (!token) return; setIsLoading(true); setError(null); try { const [statsData, logsData] = await Promise.all([ authLogsApi.getStats({ ...activeFilters, sms_unit_cost: unitCost }, token), authLogsApi.getLogs({ ...activeFilters, page: targetPage, per_page: 30 }, token), ]); setStats(statsData); setLogs(logsData); } catch (err) { setError(err instanceof Error ? err.message : 'خطا در دریافت آمار ورود و پیامک'); } finally { setIsLoading(false); } }, [token]); useEffect(() => { if (!token) return; packagesApi.getAllPackages(token).then(setPackages).catch(() => setPackages([])); }, [token]); // First load: restore the last SMS unit price the admin entered, then fetch // today's activity with it (so the cost card is right on the very first render). useEffect(() => { if (!token || initialized) return; const saved = window.localStorage.getItem(SMS_COST_STORAGE_KEY); const parsed = saved ? parseInt(saved, 10) : NaN; const unitCost = !Number.isNaN(parsed) && parsed >= 0 ? parsed : DEFAULT_SMS_UNIT_COST; setSmsUnitCost(unitCost); setInitialized(true); load(1, createDefaultFilters(), unitCost); }, [token, initialized, load]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFilters((prev) => ({ ...prev, [name]: value })); }; const handleUnitCostChange = (e: React.ChangeEvent) => { const digits = e.target.value.replace(/[^\d]/g, ''); const parsed = digits ? parseInt(digits, 10) : 0; setSmsUnitCost(parsed); window.localStorage.setItem(SMS_COST_STORAGE_KEY, String(parsed)); }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); setPage(1); load(1, filters, smsUnitCost); }; const handleReset = () => { const defaults = createDefaultFilters(); setFilters(defaults); setSmsUnitCost(DEFAULT_SMS_UNIT_COST); window.localStorage.setItem(SMS_COST_STORAGE_KEY, String(DEFAULT_SMS_UNIT_COST)); setPage(1); load(1, defaults, DEFAULT_SMS_UNIT_COST); }; const goToPage = (targetPage: number) => { setPage(targetPage); load(targetPage, filters, smsUnitCost); }; // Tallest bar in the daily chart, used to scale the rest const dailyMax = useMemo( () => Math.max(1, ...(stats?.daily || []).map((d) => Math.max(d.otp_sent, d.otp_verified))), [stats] ); const inputClass = 'block w-full px-3 py-2.5 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent sm:text-sm dark:bg-gray-800/80 transition-colors'; const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5'; const cards = stats ? [ { title: 'پیامک ارسال‌شده', value: formatPrice(stats.totals.sms_sent), hint: 'مجموع کدهای ارسالی (قابل محاسبه برای هزینه)', Icon: DevicePhoneMobileIcon, iconBg: 'bg-amber-100 dark:bg-amber-500/15', iconColor: 'text-amber-600 dark:text-amber-400', }, { title: 'هزینه تخمینی پیامک', value: `${formatPrice(stats.totals.sms_cost)} تومان`, hint: `بر پایه ${formatPrice(stats.totals.sms_unit_cost)} تومان برای هر پیامک`, Icon: BanknotesIcon, iconBg: 'bg-cyan-100 dark:bg-cyan-500/15', iconColor: 'text-cyan-600 dark:text-cyan-400', }, { title: 'ورود موفق', value: formatPrice(stats.totals.logins), hint: 'تأیید کد + رمز عبور + گوگل', Icon: ArrowRightOnRectangleIcon, iconBg: 'bg-green-100 dark:bg-green-500/15', iconColor: 'text-green-600 dark:text-green-400', }, { title: 'نرخ تأیید کد', value: `${formatPrice(stats.totals.otp_conversion_rate)}٪`, hint: `${formatPrice(stats.totals.otp_verified)} تأیید از ${formatPrice(stats.totals.otp_sent)} ارسال`, Icon: CheckBadgeIcon, iconBg: 'bg-indigo-100 dark:bg-indigo-500/15', iconColor: 'text-indigo-600 dark:text-indigo-400', }, { title: 'کاربران یکتا', value: formatPrice(stats.totals.unique_mobiles), hint: 'شماره‌های یکتا در این بازه', Icon: UserGroupIcon, iconBg: 'bg-purple-100 dark:bg-purple-500/15', iconColor: 'text-purple-600 dark:text-purple-400', }, { title: 'کد نامعتبر', value: formatPrice(stats.totals.otp_failed), hint: 'پیامک‌هایی که به ورود نرسیدند', Icon: ExclamationTriangleIcon, iconBg: 'bg-red-100 dark:bg-red-500/15', iconColor: 'text-red-600 dark:text-red-400', }, ] : []; return (
{/* Filters */}
setFilters((prev) => ({ ...prev, date_from: value }))} />
setFilters((prev) => ({ ...prev, date_to: value }))} />
{error && (

{error}

)} {/* Summary cards */}
{cards.map(({ title, value, hint, Icon, iconBg, iconColor }) => (

{title}

{value}

{hint}

))}
{/* Daily chart: sent vs verified OTPs */} {stats && stats.daily.length > 0 && (

روند روزانه

کد ارسالی کد تأییدشده
{stats.daily.map((day) => (
{formatDate(day.date)}
))}
)} {/* Breakdown by event and package */} {stats && (

تفکیک بر اساس رویداد

    {Object.entries(AUTH_EVENTS).map(([key, e]) => (
  • {e.label} {formatPrice(stats.by_event[key] ?? 0)}
  • ))}

تفکیک بر اساس پکیج

{stats.by_package.length === 0 ? (

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

) : (
    {stats.by_package.map((row) => (
  • {packages.find((p) => p.name === row.package_name)?.title || row.package_name} {formatPrice(row.sms)} پیامک {formatPrice(row.logins)} ورود
  • ))}
)}
)} {/* Raw event log */}

{logs ? `${formatPrice(logs.total)} رویداد یافت شد` : 'در حال بارگذاری...'}

{isLoading ? (
) : !logs || logs.data.length === 0 ? (

هیچ رویدادی با این فیلترها یافت نشد

) : ( <> {/* Mobile: stacked cards */}
    {logs.data.map((log: AuthLog) => (
  • {log.mobile || log.email || '—'}

    {log.package_name || '—'}

    {getAuthEventLabel(log.event)}
    {log.ip || '—'} {formatDate(log.created_at)} {formatTime(log.created_at)}
  • ))}
{/* Desktop: table */}
{logs.data.map((log: AuthLog) => ( ))}
کاربر رویداد پیامک پکیج IP تاریخ و ساعت
{log.mobile || '—'}
{log.email || '—'}
{getAuthEventLabel(log.event)} {log.sms_sent ? ( ۱ ) : ( )} {log.package_name || '—'} {log.ip || '—'} {formatDate(log.created_at)} {formatTime(log.created_at)}
)} {/* Pagination */} {logs && logs.last_page > 1 && (
صفحه {logs.current_page} از {logs.last_page}
)}
); }