+ {/* Group Header */}
+
+
+
+ {group.icon}
+
+
{group.title}
-
-
{title}
-
{description}
+
+ {/* Group Items */}
+
+ {group.items.map((item) => (
+
+ ))}
-
-
+
)
}
export default function AdminPanel() {
const { t } = useTranslation()
- const adminSections: AdminSection[] = [
+ const groups: AdminGroup[] = [
{
- to: '/admin/dashboard',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.dashboard'),
- description: t('admin.panel.dashboardDesc'),
- color: 'success',
- bgColor: 'bg-emerald-500/20',
- textColor: 'text-emerald-400'
+ id: 'analytics',
+ title: t('admin.groups.analytics', 'Аналитика'),
+ icon:
,
+ gradient: 'from-emerald-500/10 to-emerald-500/5',
+ borderColor: 'border-emerald-500/20',
+ iconBg: 'bg-emerald-500/20',
+ iconColor: 'text-emerald-400',
+ items: [
+ {
+ to: '/admin/dashboard',
+ icon:
,
+ title: t('admin.nav.dashboard'),
+ description: t('admin.panel.dashboardDesc'),
+ },
+ {
+ to: '/admin/payments',
+ icon:
,
+ title: t('admin.nav.payments', 'Платежи'),
+ description: t('admin.panel.paymentsDesc', 'История и проверка платежей'),
+ },
+ ],
},
{
- to: '/admin/tickets',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.tickets'),
- description: t('admin.panel.ticketsDesc'),
- color: 'warning',
- bgColor: 'bg-amber-500/20',
- textColor: 'text-amber-400'
+ id: 'users',
+ title: t('admin.groups.users', 'Пользователи'),
+ icon:
,
+ gradient: 'from-blue-500/10 to-blue-500/5',
+ borderColor: 'border-blue-500/20',
+ iconBg: 'bg-blue-500/20',
+ iconColor: 'text-blue-400',
+ items: [
+ {
+ to: '/admin/users',
+ icon:
,
+ title: t('admin.nav.users', 'Пользователи'),
+ description: t('admin.panel.usersDesc', 'Управление пользователями'),
+ },
+ {
+ to: '/admin/tickets',
+ icon:
,
+ title: t('admin.nav.tickets'),
+ description: t('admin.panel.ticketsDesc'),
+ },
+ {
+ to: '/admin/ban-system',
+ icon:
,
+ title: t('admin.nav.banSystem'),
+ description: t('admin.panel.banSystemDesc'),
+ },
+ ],
},
{
- to: '/admin/settings',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.settings'),
- description: t('admin.panel.settingsDesc'),
- color: 'accent',
- bgColor: 'bg-blue-500/20',
- textColor: 'text-blue-400'
+ id: 'tariffs',
+ title: t('admin.groups.tariffs', 'Тарифы и продажи'),
+ icon:
,
+ gradient: 'from-amber-500/10 to-amber-500/5',
+ borderColor: 'border-amber-500/20',
+ iconBg: 'bg-amber-500/20',
+ iconColor: 'text-amber-400',
+ items: [
+ {
+ to: '/admin/tariffs',
+ icon:
,
+ title: t('admin.nav.tariffs'),
+ description: t('admin.panel.tariffsDesc'),
+ },
+ {
+ to: '/admin/promocodes',
+ icon:
,
+ title: t('admin.nav.promocodes', 'Промокоды'),
+ description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
+ },
+ {
+ to: '/admin/promo-offers',
+ icon:
,
+ title: t('admin.nav.promoOffers', 'Промопредложения'),
+ description: t('admin.panel.promoOffersDesc', 'Персональные скидки'),
+ },
+ ],
},
{
- to: '/admin/apps',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.apps'),
- description: t('admin.panel.appsDesc'),
- color: 'success',
- bgColor: 'bg-teal-500/20',
- textColor: 'text-teal-400'
+ id: 'marketing',
+ title: t('admin.groups.marketing', 'Маркетинг'),
+ icon:
,
+ gradient: 'from-rose-500/10 to-rose-500/5',
+ borderColor: 'border-rose-500/20',
+ iconBg: 'bg-rose-500/20',
+ iconColor: 'text-rose-400',
+ items: [
+ {
+ to: '/admin/campaigns',
+ icon:
,
+ title: t('admin.nav.campaigns', 'Кампании'),
+ description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
+ },
+ {
+ to: '/admin/broadcasts',
+ icon:
,
+ title: t('admin.nav.broadcasts'),
+ description: t('admin.panel.broadcastsDesc'),
+ },
+ {
+ to: '/admin/wheel',
+ icon:
,
+ title: t('admin.nav.wheel'),
+ description: t('admin.panel.wheelDesc'),
+ },
+ ],
},
{
- to: '/admin/wheel',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.wheel'),
- description: t('admin.panel.wheelDesc'),
- color: 'error',
- bgColor: 'bg-rose-500/20',
- textColor: 'text-rose-400'
- },
- {
- to: '/admin/tariffs',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.tariffs'),
- description: t('admin.panel.tariffsDesc'),
- color: 'info',
- bgColor: 'bg-cyan-500/20',
- textColor: 'text-cyan-400'
- },
- {
- to: '/admin/servers',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.servers'),
- description: t('admin.panel.serversDesc'),
- color: 'purple',
- bgColor: 'bg-purple-500/20',
- textColor: 'text-purple-400'
- },
- {
- to: '/admin/broadcasts',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.broadcasts'),
- description: t('admin.panel.broadcastsDesc'),
- color: 'orange',
- bgColor: 'bg-orange-500/20',
- textColor: 'text-orange-400'
- },
- {
- to: '/admin/promocodes',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.promocodes', 'Промокоды'),
- description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
- color: 'violet',
- bgColor: 'bg-violet-500/20',
- textColor: 'text-violet-400'
- },
- {
- to: '/admin/promo-offers',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.promoOffers', 'Промопредложения'),
- description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
- color: 'orange',
- bgColor: 'bg-orange-500/20',
- textColor: 'text-orange-400'
- },
- {
- to: '/admin/campaigns',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.campaigns', 'Кампании'),
- description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
- color: 'orange',
- bgColor: 'bg-orange-500/20',
- textColor: 'text-orange-400'
- },
- {
- to: '/admin/users',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.users', 'Пользователи'),
- description: t('admin.panel.usersDesc', 'Управление пользователями'),
- color: 'indigo',
- bgColor: 'bg-indigo-500/20',
- textColor: 'text-indigo-400'
- },
- {
- to: '/admin/ban-system',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.banSystem'),
- description: t('admin.panel.banSystemDesc'),
- color: 'error',
- bgColor: 'bg-red-500/20',
- textColor: 'text-red-400'
- },
- {
- to: '/admin/payments',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.payments', 'Платежи'),
- description: t('admin.panel.paymentsDesc', 'Проверка платежей'),
- color: 'lime',
- bgColor: 'bg-lime-500/20',
- textColor: 'text-lime-400'
- },
- {
- to: '/admin/remnawave',
- icon:
,
- mobileIcon:
,
- title: t('admin.nav.remnawave', 'RemnaWave'),
- description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
- color: 'purple',
- bgColor: 'bg-purple-500/20',
- textColor: 'text-purple-400'
+ id: 'system',
+ title: t('admin.groups.system', 'Система'),
+ icon:
,
+ gradient: 'from-violet-500/10 to-violet-500/5',
+ borderColor: 'border-violet-500/20',
+ iconBg: 'bg-violet-500/20',
+ iconColor: 'text-violet-400',
+ items: [
+ {
+ to: '/admin/settings',
+ icon:
,
+ title: t('admin.nav.settings'),
+ description: t('admin.panel.settingsDesc'),
+ },
+ {
+ to: '/admin/apps',
+ icon:
,
+ title: t('admin.nav.apps'),
+ description: t('admin.panel.appsDesc'),
+ },
+ {
+ to: '/admin/servers',
+ icon:
,
+ title: t('admin.nav.servers'),
+ description: t('admin.panel.serversDesc'),
+ },
+ {
+ to: '/admin/remnawave',
+ icon:
,
+ title: t('admin.nav.remnawave', 'RemnaWave'),
+ description: t('admin.panel.remnawaveDesc', 'Управление панелью'),
+ },
+ ],
},
]
return (
-
- {/* Header - compact on mobile */}
-
-
-
-
{t('admin.panel.title')}
-
{t('admin.panel.subtitle')}
-
+
+ {/* Header */}
+
+
{t('admin.panel.title')}
+
{t('admin.panel.subtitle')}
- {/* Mobile: Compact 2-column grid */}
-
- {adminSections.map((section) => (
-
- ))}
-
-
- {/* Tablet/Desktop: List style */}
-
- {adminSections.map((section) => (
-
+ {/* Groups Grid */}
+
+ {groups.map((group) => (
+
))}
diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx
index 9d6b622..1206b13 100644
--- a/src/pages/AdminTickets.tsx
+++ b/src/pages/AdminTickets.tsx
@@ -460,6 +460,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
sla_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60,
sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15,
support_system_mode: settings?.support_system_mode ?? 'both',
+ cabinet_user_notifications_enabled: settings?.cabinet_user_notifications_enabled ?? true,
+ cabinet_admin_notifications_enabled: settings?.cabinet_admin_notifications_enabled ?? true,
})
// Update form when settings load
@@ -471,6 +473,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
sla_check_interval_seconds: settings.sla_check_interval_seconds,
sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes,
support_system_mode: settings.support_system_mode,
+ cabinet_user_notifications_enabled: settings.cabinet_user_notifications_enabled ?? true,
+ cabinet_admin_notifications_enabled: settings.cabinet_admin_notifications_enabled ?? true,
})
}
}, [settings])
@@ -526,6 +530,43 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
{t('admin.tickets.supportModeDesc')}
+ {/* Cabinet Notifications */}
+
+
{t('admin.tickets.cabinetNotifications')}
+
+ {/* User Notifications */}
+
+
+
+
+ {/* Admin Notifications */}
+
+
+
+
+
{t('admin.tickets.slaSettings')}
diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx
index fa3bc04..042ecbf 100644
--- a/src/pages/AdminWheel.tsx
+++ b/src/pages/AdminWheel.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
-import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel'
+import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'
// Icons
const BackIcon = () => (
@@ -468,7 +468,7 @@ export default function AdminWheel() {
if (editingPrize) {
updatePrizeMutation.mutate({ id: editingPrize.id, data })
} else {
- createPrizeMutation.mutate(data as any)
+ createPrizeMutation.mutate(data as CreateWheelPrizeData)
}
}}
/>
diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx
index 1fea4b3..fa78f8a 100644
--- a/src/pages/DeepLinkRedirect.tsx
+++ b/src/pages/DeepLinkRedirect.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback } from 'react'
+import { useEffect, useState, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
@@ -46,6 +46,8 @@ export default function DeepLinkRedirect() {
const [status, setStatus] = useState
('countdown')
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
const [copied, setCopied] = useState(false)
+ const fallbackTimeoutRef = useRef | null>(null)
+ const copiedTimeoutRef = useRef | null>(null)
// Get branding
const { data: branding } = useQuery({
@@ -135,23 +137,34 @@ export default function DeepLinkRedirect() {
if (prev <= 1) {
clearInterval(timer)
openDeepLink()
- // Show fallback after a delay
- setTimeout(() => setStatus('fallback'), 2000)
+ // Show fallback after a delay - store ref for cleanup
+ fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000)
return 0
}
return prev - 1
})
}, 1000)
- return () => clearInterval(timer)
+ return () => {
+ clearInterval(timer)
+ // Cleanup fallback timeout on unmount
+ if (fallbackTimeoutRef.current) {
+ clearTimeout(fallbackTimeoutRef.current)
+ fallbackTimeoutRef.current = null
+ }
+ }
}, [deepLink, status, openDeepLink])
const handleCopyLink = async () => {
const linkToCopy = subscriptionUrl || deepLink
+ // Clear previous timeout to prevent stacking
+ if (copiedTimeoutRef.current) {
+ clearTimeout(copiedTimeoutRef.current)
+ }
try {
await navigator.clipboard.writeText(linkToCopy)
setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = linkToCopy
@@ -160,10 +173,19 @@ export default function DeepLinkRedirect() {
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
}
}
+ // Cleanup copied timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (copiedTimeoutRef.current) {
+ clearTimeout(copiedTimeoutRef.current)
+ }
+ }
+ }, [])
+
// Progress percentage
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index c6e48ca..d6a8c85 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -117,7 +117,9 @@ export default function Login() {
await loginWithTelegram(tg.initData)
navigate(getReturnUrl(), { replace: true })
} catch (err) {
- console.error('Telegram auth failed:', err)
+ // Log only status code to avoid leaking sensitive data
+ const status = (err as { response?: { status?: number } })?.response?.status
+ console.warn('Telegram auth failed with status:', status)
setError(t('auth.telegramRequired'))
} finally {
setIsLoading(false)
@@ -137,8 +139,16 @@ export default function Login() {
await loginWithEmail(email, password)
navigate(getReturnUrl(), { replace: true })
} catch (err: unknown) {
- const error = err as { response?: { data?: { detail?: string } } }
- setError(error.response?.data?.detail || t('common.error'))
+ const error = err as { response?: { status?: number; data?: { detail?: string } } }
+ // Show user-friendly error messages without exposing sensitive server details
+ const status = error.response?.status
+ if (status === 401 || status === 403) {
+ setError(t('auth.invalidCredentials', 'Неверный email или пароль'))
+ } else if (status === 429) {
+ setError(t('auth.tooManyAttempts', 'Слишком много попыток. Попробуйте позже'))
+ } else {
+ setError(t('common.error'))
+ }
} finally {
setIsLoading(false)
}
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx
index e161c40..060a3e2 100644
--- a/src/pages/Support.tsx
+++ b/src/pages/Support.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef } from 'react'
+import { useState, useRef, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
@@ -91,6 +91,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
@@ -141,6 +142,29 @@ export default function Support() {
const createFileInputRef = useRef(null)
const replyFileInputRef = useRef(null)
+ // Cleanup function to revoke object URLs and prevent memory leaks
+ const clearAttachment = useCallback((
+ attachment: MediaAttachment | null,
+ setAttachment: (a: MediaAttachment | null) => void
+ ) => {
+ if (attachment?.preview) {
+ URL.revokeObjectURL(attachment.preview)
+ }
+ setAttachment(null)
+ }, [])
+
+ // Cleanup on unmount to prevent memory leaks
+ useEffect(() => {
+ return () => {
+ if (createAttachment?.preview) {
+ URL.revokeObjectURL(createAttachment.preview)
+ }
+ if (replyAttachment?.preview) {
+ URL.revokeObjectURL(replyAttachment.preview)
+ }
+ }
+ }, []) // Empty deps - only run on unmount
+
// Get support configuration
const { data: supportConfig, isLoading: configLoading } = useQuery({
queryKey: ['support-config'],
@@ -162,8 +186,14 @@ export default function Support() {
// Handle file selection
const handleFileSelect = async (
file: File,
- setAttachment: (a: MediaAttachment | null) => void
+ setAttachment: (a: MediaAttachment | null) => void,
+ currentAttachment: MediaAttachment | null
) => {
+ // Revoke old object URL before creating new one
+ if (currentAttachment?.preview) {
+ URL.revokeObjectURL(currentAttachment.preview)
+ }
+
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
@@ -224,7 +254,7 @@ export default function Support() {
setShowCreateForm(false)
setNewTitle('')
setNewMessage('')
- setCreateAttachment(null)
+ clearAttachment(createAttachment, setCreateAttachment)
setSelectedTicket(ticket)
},
})
@@ -242,7 +272,7 @@ export default function Support() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] })
setReplyMessage('')
- setReplyAttachment(null)
+ clearAttachment(replyAttachment, setReplyAttachment)
},
})
@@ -443,7 +473,7 @@ export default function Support() {
onClick={() => {
setShowCreateForm(true)
setSelectedTicket(null)
- setCreateAttachment(null)
+ clearAttachment(createAttachment, setCreateAttachment)
}}
className="btn-primary"
>
@@ -469,7 +499,7 @@ export default function Support() {
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail)
setShowCreateForm(false)
- setReplyAttachment(null)
+ clearAttachment(replyAttachment, setReplyAttachment)
}}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selectedTicket?.id === ticket.id
@@ -555,14 +585,14 @@ export default function Support() {
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
- if (file) handleFileSelect(file, setCreateAttachment)
+ if (file) handleFileSelect(file, setCreateAttachment, createAttachment)
e.target.value = ''
}}
/>
{createAttachment ? (
setCreateAttachment(null)}
+ onRemove={() => clearAttachment(createAttachment, setCreateAttachment)}
/>
) : (