diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e57b3..8414a20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,24 @@ jobs: lint-and-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' + - name: Install dependencies run: npm ci + - name: Run ESLint run: npm run lint + - name: Run TypeScript check run: npx tsc --noEmit + - name: Build run: npm run build env: @@ -27,6 +33,7 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: test_bot VITE_APP_NAME: Cabinet VITE_APP_LOGO: V + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: diff --git a/Dockerfile b/Dockerfile index 8293e10..de296d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /app COPY package.json package-lock.json* ./ # Install dependencies -# Use npm install if no lock file, npm ci if lock file exists RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi # Copy source code diff --git a/src/App.tsx b/src/App.tsx index 48f2c17..d827e18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,39 +1,46 @@ +import { lazy, Suspense } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' import { saveReturnUrl } from './utils/token' + +// Auth pages - load immediately (small) import Login from './pages/Login' import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect' -import Dashboard from './pages/Dashboard' -import Subscription from './pages/Subscription' -import Balance from './pages/Balance' -import Referral from './pages/Referral' -import Support from './pages/Support' -import Profile from './pages/Profile' -import AdminTickets from './pages/AdminTickets' -import AdminSettings from './pages/AdminSettings' -import AdminApps from './pages/AdminApps' import VerifyEmail from './pages/VerifyEmail' -import Contests from './pages/Contests' -import Polls from './pages/Polls' -import Info from './pages/Info' -import Wheel from './pages/Wheel' -import AdminWheel from './pages/AdminWheel' -import AdminTariffs from './pages/AdminTariffs' -import AdminServers from './pages/AdminServers' -import AdminPanel from './pages/AdminPanel' -import AdminDashboard from './pages/AdminDashboard' -import AdminBanSystem from './pages/AdminBanSystem' -import AdminBroadcasts from './pages/AdminBroadcasts' -import AdminPromocodes from './pages/AdminPromocodes' -import AdminCampaigns from './pages/AdminCampaigns' -import AdminUsers from './pages/AdminUsers' -import AdminPayments from './pages/AdminPayments' -import AdminPromoOffers from './pages/AdminPromoOffers' -import AdminRemnawave from './pages/AdminRemnawave' + +// User pages - lazy load +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Subscription = lazy(() => import('./pages/Subscription')) +const Balance = lazy(() => import('./pages/Balance')) +const Referral = lazy(() => import('./pages/Referral')) +const Support = lazy(() => import('./pages/Support')) +const Profile = lazy(() => import('./pages/Profile')) +const Contests = lazy(() => import('./pages/Contests')) +const Polls = lazy(() => import('./pages/Polls')) +const Info = lazy(() => import('./pages/Info')) +const Wheel = lazy(() => import('./pages/Wheel')) + +// Admin pages - lazy load (only for admins) +const AdminPanel = lazy(() => import('./pages/AdminPanel')) +const AdminTickets = lazy(() => import('./pages/AdminTickets')) +const AdminSettings = lazy(() => import('./pages/AdminSettings')) +const AdminApps = lazy(() => import('./pages/AdminApps')) +const AdminWheel = lazy(() => import('./pages/AdminWheel')) +const AdminTariffs = lazy(() => import('./pages/AdminTariffs')) +const AdminServers = lazy(() => import('./pages/AdminServers')) +const AdminDashboard = lazy(() => import('./pages/AdminDashboard')) +const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')) +const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')) +const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) +const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) +const AdminUsers = lazy(() => import('./pages/AdminUsers')) +const AdminPayments = lazy(() => import('./pages/AdminPayments')) +const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) +const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() @@ -73,6 +80,15 @@ function AdminRoute({ children }: { children: React.ReactNode }) { return {children} } +// Suspense wrapper for lazy components +function LazyPage({ children }: { children: React.ReactNode }) { + return ( + }> + {children} + + ) +} + function App() { return ( @@ -90,7 +106,7 @@ function App() { path="/" element={ - + } /> @@ -98,7 +114,7 @@ function App() { path="/subscription" element={ - + } /> @@ -106,7 +122,7 @@ function App() { path="/balance" element={ - + } /> @@ -114,7 +130,7 @@ function App() { path="/referral" element={ - + } /> @@ -122,7 +138,7 @@ function App() { path="/support" element={ - + } /> @@ -130,7 +146,7 @@ function App() { path="/profile" element={ - + } /> @@ -138,7 +154,7 @@ function App() { path="/contests" element={ - + } /> @@ -146,7 +162,7 @@ function App() { path="/polls" element={ - + } /> @@ -154,7 +170,7 @@ function App() { path="/info" element={ - + } /> @@ -162,7 +178,7 @@ function App() { path="/wheel" element={ - + } /> @@ -172,7 +188,7 @@ function App() { path="/admin" element={ - + } /> @@ -180,7 +196,7 @@ function App() { path="/admin/tickets" element={ - + } /> @@ -188,7 +204,7 @@ function App() { path="/admin/settings" element={ - + } /> @@ -196,7 +212,7 @@ function App() { path="/admin/apps" element={ - + } /> @@ -204,7 +220,7 @@ function App() { path="/admin/wheel" element={ - + } /> @@ -212,7 +228,7 @@ function App() { path="/admin/tariffs" element={ - + } /> @@ -220,7 +236,7 @@ function App() { path="/admin/servers" element={ - + } /> @@ -228,7 +244,7 @@ function App() { path="/admin/dashboard" element={ - + } /> @@ -236,7 +252,7 @@ function App() { path="/admin/ban-system" element={ - + } /> @@ -244,7 +260,7 @@ function App() { path="/admin/broadcasts" element={ - + } /> @@ -252,7 +268,7 @@ function App() { path="/admin/promocodes" element={ - + } /> @@ -260,7 +276,7 @@ function App() { path="/admin/campaigns" element={ - + } /> @@ -268,7 +284,7 @@ function App() { path="/admin/users" element={ - + } /> @@ -276,7 +292,7 @@ function App() { path="/admin/payments" element={ - + } /> @@ -284,7 +300,7 @@ function App() { path="/admin/promo-offers" element={ - + } /> @@ -292,7 +308,7 @@ function App() { path="/admin/remnawave" element={ - + } /> diff --git a/src/api/admin.ts b/src/api/admin.ts index acc3d91..cc935f6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -59,6 +59,8 @@ export interface TicketSettings { sla_check_interval_seconds: number sla_reminder_cooldown_minutes: number support_system_mode: string // tickets, contact, both + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean } export interface TicketSettingsUpdate { @@ -67,6 +69,8 @@ export interface TicketSettingsUpdate { sla_check_interval_seconds?: number sla_reminder_cooldown_minutes?: number support_system_mode?: string + cabinet_user_notifications_enabled?: boolean + cabinet_admin_notifications_enabled?: boolean } export interface AdminTicketListResponse { diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 51a69af..b57a78c 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -9,6 +9,7 @@ export interface LocalizedText { } export interface AppButton { + id?: string // Unique identifier for React key (client-side only) buttonLink: string buttonText: LocalizedText } diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts new file mode 100644 index 0000000..36a30b3 --- /dev/null +++ b/src/api/ticketNotifications.ts @@ -0,0 +1,64 @@ +import apiClient from './client' +import type { TicketNotificationList, UnreadCountResponse } from '../types' + +export const ticketNotificationsApi = { + // User notifications + getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') + return response.data + }, + + markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/tickets/notifications/read-all') + return response.data + }, + + markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, + + // Admin notifications + getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const params: Record = { limit, offset } + if (unreadOnly) { + params.unread_only = true + } + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params }) + return response.data + }, + + getAdminUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') + return response.data + }, + + markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') + return response.data + }, + + markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, +} + +export default ticketNotificationsApi diff --git a/src/api/wheel.ts b/src/api/wheel.ts index c076e3b..7511f94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -98,6 +98,22 @@ export interface WheelPrizeAdmin { updated_at: string | null } +// Type for creating a new prize (excludes id, config_id which are auto-generated) +export interface CreateWheelPrizeData { + prize_type: string + prize_value: number + display_name: string + emoji?: string + color?: string + prize_value_kopeks: number + sort_order?: number + manual_probability?: number | null + is_active?: boolean + promo_balance_bonus_kopeks?: number + promo_subscription_days?: number + promo_traffic_gb?: number +} + export interface AdminWheelConfig { id: number is_enabled: boolean @@ -223,20 +239,7 @@ export const adminWheelApi = { }, // Create prize - createPrize: async (data: { - prize_type: string - prize_value: number - display_name: string - emoji?: string - color?: string - prize_value_kopeks: number - sort_order?: number - manual_probability?: number | null - is_active?: boolean - promo_balance_bonus_kopeks?: number - promo_subscription_days?: number - promo_traffic_gb?: number - }): Promise => { + createPrize: async (data: CreateWheelPrizeData): Promise => { const response = await apiClient.post('/cabinet/admin/wheel/prizes', data) return response.data }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 9889dd0..e841b7a 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -81,14 +81,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C {description &&

{description}

}
- {/* Color preview button */} + {/* Color preview button - min 44px for touch accessibility */}
diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2ac3e2b..ef89184 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -286,7 +286,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{t('subscription.connection.title')}

- + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

+ {t('notifications.ticketNotifications', 'Ticket Notifications')} +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Notifications list */} +
+ {isLoading ? ( +
+
+
+ ) : notificationsData?.items && notificationsData.items.length > 0 ? ( + notificationsData.items.map((notification: TicketNotification) => ( + + )) + ) : ( +
+
+ +
+

{t('notifications.noNotifications', 'No notifications')}

+
+ )} +
+ + {/* Footer */} + {notificationsData?.items && notificationsData.items.length > 0 && ( +
+ +
+ )} +
+ )} + + ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..950025a --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,210 @@ +import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react' + +interface ToastOptions { + type?: 'success' | 'error' | 'info' | 'warning' + message: string + title?: string + icon?: ReactNode + duration?: number + onClick?: () => void +} + +interface Toast extends ToastOptions { + id: number +} + +interface ToastContextType { + showToast: (options: ToastOptions) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) + + const showToast = useCallback((options: ToastOptions) => { + const id = Date.now() + Math.random() // Avoid ID collision + const toast: Toast = { id, duration: 5000, type: 'info', ...options } + + setToasts(prev => [...prev, toast]) + + const timer = setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + timersRef.current.delete(id) + }, toast.duration) + + timersRef.current.set(id, timer) + }, []) + + const removeToast = useCallback((id: number) => { + // Clear timer when manually removing + const timer = timersRef.current.get(id) + if (timer) { + clearTimeout(timer) + timersRef.current.delete(id) + } + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + // Cleanup all timers on unmount + useEffect(() => { + const timers = timersRef.current + return () => { + timers.forEach(timer => clearTimeout(timer)) + timers.clear() + } + }, []) + + return ( + + {children} + + {/* Toast Container */} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+
+ ) +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + const handleClick = () => { + if (toast.onClick) { + toast.onClick() + onClose() + } + } + + const typeStyles = { + success: { + bg: 'bg-gradient-to-r from-success-500/20 to-success-600/10', + border: 'border-success-500/30', + icon: 'text-success-400', + iconBg: 'bg-success-500/20', + }, + error: { + bg: 'bg-gradient-to-r from-error-500/20 to-error-600/10', + border: 'border-error-500/30', + icon: 'text-error-400', + iconBg: 'bg-error-500/20', + }, + warning: { + bg: 'bg-gradient-to-r from-warning-500/20 to-warning-600/10', + border: 'border-warning-500/30', + icon: 'text-warning-400', + iconBg: 'bg-warning-500/20', + }, + info: { + bg: 'bg-gradient-to-r from-accent-500/20 to-accent-600/10', + border: 'border-accent-500/30', + icon: 'text-accent-400', + iconBg: 'bg-accent-500/20', + }, + } + + const style = typeStyles[toast.type || 'info'] + + const defaultIcons = { + success: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ), + } + + return ( +
+ {/* Glow effect */} +
+ +
+
+ {/* Icon */} +
+ {toast.icon || defaultIcons[toast.type || 'info']} +
+ + {/* Content */} +
+ {toast.title && ( +

+ {toast.title} +

+ )} +

+ {toast.message} +

+
+ + {/* Close button */} + +
+ + {/* Progress bar */} +
+
+
+
+
+ ) +} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 2486d1b..6f37085 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -9,23 +9,55 @@ import type { PaymentMethod } from '../types' const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url) -const openPaymentLink = (url: string, reservedWindow?: Window | null) => { - if (typeof window === 'undefined' || !url) return +/** + * Открывает платёжную ссылку разными способами в зависимости от окружения. + * Возвращает true если ссылка успешно открыта, false если все методы не сработали. + */ +const openPaymentLink = (url: string, reservedWindow?: Window | null): boolean => { + if (typeof window === 'undefined' || !url) return false const webApp = window.Telegram?.WebApp + // Попытка 1: Telegram openTelegramLink для t.me ссылок if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { - try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } + try { + webApp.openTelegramLink(url) + return true + } catch (e) { + console.warn('[TopUpModal] openTelegramLink failed:', e) + } } + + // Попытка 2: Telegram WebApp openLink if (webApp?.openLink) { - try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } + try { + webApp.openLink(url, { try_instant_view: false }) + return true + } catch (e) { + console.warn('[TopUpModal] webApp.openLink failed:', e) + } } + + // Попытка 3: Зарезервированное окно браузера if (reservedWindow && !reservedWindow.closed) { - try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } - return + try { + reservedWindow.location.href = url + reservedWindow.focus?.() + return true + } catch (e) { + console.warn('[TopUpModal] Failed to use reserved window:', e) + } } + + // Попытка 4: window.open const w2 = window.open(url, '_blank', 'noopener,noreferrer') - if (w2) { w2.opener = null; return } + if (w2) { + w2.opener = null + return true + } + + // Последний вариант: редирект текущей страницы window.location.href = url + return true } interface TopUpModalProps { @@ -82,24 +114,47 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top }) const topUpMutation = useMutation<{ - payment_id: string; payment_url?: string; invoice_url?: string - amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null + payment_id: string + payment_url?: string + invoice_url?: string + amount_kopeks: number + amount_rubles: number + status: string + expires_at: string | null }, unknown, number>({ mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), onSuccess: (data) => { - const redirectUrl = data.payment_url || (data as any).invoice_url - if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current) + const redirectUrl = data.payment_url || data.invoice_url + if (!redirectUrl) { + setError(t('balance.noPaymentUrl', 'Сервер не вернул ссылку для оплаты')) + closePopupSafely() + return + } + const opened = openPaymentLink(redirectUrl, popupRef.current) + if (!opened) { + setError(t('balance.openLinkFailed', 'Не удалось открыть страницу оплаты')) + } popupRef.current = null onClose() }, onError: (err: unknown) => { - try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} - popupRef.current = null + closePopupSafely() const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) }, }) + const closePopupSafely = () => { + try { + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close() + } + } catch (e) { + console.warn('[TopUpModal] Failed to close popup:', e) + } + popupRef.current = null + } + const handleSubmit = () => { setError(null) inputRef.current?.blur() @@ -132,14 +187,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return ( -
+
{/* Header */}
{methodName} - @@ -365,6 +384,8 @@ export default function Layout({ children }: LayoutProps) { @@ -387,29 +408,33 @@ export default function Layout({ children }: LayoutProps) {
{/* User info */} -
- {userPhotoUrl ? ( - Avatar { - e.currentTarget.style.display = 'none' - e.currentTarget.nextElementSibling?.classList.remove('hidden') - }} - /> - ) : null} -
- -
-
-
- {user?.first_name || user?.username} +
+
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} +
+
-
- @{user?.username || `ID: ${user?.telegram_id}`} +
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+ {/* Language switcher in mobile menu when promo is active */} + {isPromoActive && }
{/* Nav items */} @@ -478,6 +503,7 @@ export default function Layout({ children }: LayoutProps) {
{children}
+ {/* Mobile Bottom Navigation - only core items */} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..da267a6 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,156 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useAuthStore } from '../store/auth' + +const isDev = import.meta.env.DEV + +export interface WSMessage { + type: string + ticket_id?: number + message?: string + title?: string + user_id?: number + is_admin?: boolean +} + +interface UseWebSocketOptions { + onMessage?: (message: WSMessage) => void + onConnect?: () => void + onDisconnect?: () => void +} + +export function useWebSocket(options: UseWebSocketOptions = {}) { + const { accessToken, isAuthenticated } = useAuthStore() + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef | null>(null) + const pingIntervalRef = useRef | null>(null) + const [isConnected, setIsConnected] = useState(false) + const reconnectAttemptsRef = useRef(0) + const maxReconnectAttempts = 5 + const optionsRef = useRef(options) + + // Update options ref when they change + useEffect(() => { + optionsRef.current = options + }, [options]) + + const cleanup = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + }, []) + + const connect = useCallback(() => { + if (!accessToken || !isAuthenticated) { + return + } + + // Don't reconnect if already connected + if (wsRef.current?.readyState === WebSocket.OPEN) { + return + } + + cleanup() + + // Build WebSocket URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + let host = window.location.host + + // Handle VITE_API_URL - can be absolute URL or relative path + const apiUrl = import.meta.env.VITE_API_URL + if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) { + try { + host = new URL(apiUrl).host + } catch { + // If URL parsing fails, use window.location.host + } + } + // If apiUrl is relative (like /api), use window.location.host + + const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + if (isDev) console.log('[WS] Connected') + setIsConnected(true) + reconnectAttemptsRef.current = 0 + optionsRef.current.onConnect?.() + + // Setup ping interval (every 25 seconds) + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, 25000) + } + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WSMessage + + // Ignore pong messages + if (message.type === 'pong' || message.type === 'connected') { + return + } + + optionsRef.current.onMessage?.(message) + } catch (e) { + if (isDev) console.error('[WS] Failed to parse message:', e) + } + } + + ws.onclose = (event) => { + if (isDev) console.log('[WS] Disconnected:', event.code, event.reason) + setIsConnected(false) + optionsRef.current.onDisconnect?.() + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + + // Attempt to reconnect if not closed intentionally + if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000) + if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++ + connect() + }, delay) + } + } + + ws.onerror = (error) => { + if (isDev) console.error('[WS] Error:', error) + } + } catch (e) { + if (isDev) console.error('[WS] Failed to connect:', e) + } + }, [accessToken, isAuthenticated, cleanup]) + + // Connect when authenticated + useEffect(() => { + if (isAuthenticated && accessToken) { + connect() + } else { + cleanup() + setIsConnected(false) + } + + return cleanup + }, [isAuthenticated, accessToken, connect, cleanup]) + + return { isConnected } +} diff --git a/src/locales/en.json b/src/locales/en.json index 7db32a1..7feec1f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,24 @@ "info": "Info", "wheel": "Fortune Wheel" }, + "notifications": { + "ticketNotifications": "Ticket Notifications", + "markAllRead": "Mark all read", + "noNotifications": "No notifications", + "justNow": "Just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} h ago", + "daysAgo": "{{count}} d ago", + "viewAll": "View all tickets", + "newNotification": "New notification", + "clickToView": "Click to view", + "newTicket": "New ticket: {{title}}", + "newReply": "New reply in ticket: {{title}}", + "newTicketTitle": "New Ticket", + "newReplyTitle": "New Reply", + "newUserReply": "User replied in ticket: {{title}}", + "newUserReplyTitle": "User Reply" + }, "auth": { "login": "Login", "register": "Register", @@ -737,7 +755,12 @@ "reminderCooldown": "Reminder interval (minutes)", "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", "settingsUpdateError": "Error saving settings", - "copyTelegramId": "Click to copy Telegram ID" + "copyTelegramId": "Click to copy Telegram ID", + "cabinetNotifications": "Cabinet Notifications", + "userNotificationsEnabled": "User Notifications", + "userNotificationsEnabledDesc": "Send notifications to users about admin replies", + "adminNotificationsEnabled": "Admin Notifications", + "adminNotificationsEnabledDesc": "Send notifications to admins about new tickets and replies" }, "tariffs": { "title": "Tariff Management", diff --git a/src/locales/fa.json b/src/locales/fa.json index d0b5598..c0f1918 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -33,6 +33,24 @@ "info": "اطلاعات", "wheel": "چرخ شانس" }, + "notifications": { + "ticketNotifications": "اعلان‌های تیکت", + "markAllRead": "خواندن همه", + "noNotifications": "اعلانی وجود ندارد", + "justNow": "همین الان", + "minutesAgo": "{{count}} دقیقه پیش", + "hoursAgo": "{{count}} ساعت پیش", + "daysAgo": "{{count}} روز پیش", + "viewAll": "مشاهده همه تیکت‌ها", + "newNotification": "اعلان جدید", + "clickToView": "برای مشاهده کلیک کنید", + "newTicket": "تیکت جدید: {{title}}", + "newReply": "پاسخ جدید در تیکت: {{title}}", + "newTicketTitle": "تیکت جدید", + "newReplyTitle": "پاسخ جدید", + "newUserReply": "کاربر در تیکت پاسخ داد: {{title}}", + "newUserReplyTitle": "پاسخ کاربر" + }, "auth": { "login": "ورود", "register": "ثبت نام", diff --git a/src/locales/ru.json b/src/locales/ru.json index d889335..2791432 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -35,6 +35,24 @@ "info": "Информация", "wheel": "Колесо удачи" }, + "notifications": { + "ticketNotifications": "Уведомления о тикетах", + "markAllRead": "Прочитать все", + "noNotifications": "Нет уведомлений", + "justNow": "Только что", + "minutesAgo": "{{count}} мин. назад", + "hoursAgo": "{{count}} ч. назад", + "daysAgo": "{{count}} д. назад", + "viewAll": "Все тикеты", + "newNotification": "Новое уведомление", + "clickToView": "Нажмите для просмотра", + "newTicket": "Новый тикет: {{title}}", + "newReply": "Новый ответ в тикете: {{title}}", + "newTicketTitle": "Новый тикет", + "newReplyTitle": "Новый ответ", + "newUserReply": "Ответ пользователя в тикете: {{title}}", + "newUserReplyTitle": "Ответ пользователя" + }, "auth": { "login": "Вход", "register": "Регистрация", @@ -737,7 +755,12 @@ "reminderCooldown": "Интервал напоминаний (минуты)", "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", "settingsUpdateError": "Ошибка сохранения настроек", - "copyTelegramId": "Нажмите чтобы скопировать Telegram ID" + "copyTelegramId": "Нажмите чтобы скопировать Telegram ID", + "cabinetNotifications": "Уведомления в кабинете", + "userNotificationsEnabled": "Уведомления для пользователей", + "userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа", + "adminNotificationsEnabled": "Уведомления для админов", + "adminNotificationsEnabledDesc": "Отправлять уведомления админам о новых тикетах и ответах" }, "tariffs": { "title": "Управление тарифами", diff --git a/src/locales/zh.json b/src/locales/zh.json index bb0e0d2..8ce491a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -33,6 +33,24 @@ "info": "信息", "wheel": "幸运转盘" }, + "notifications": { + "ticketNotifications": "工单通知", + "markAllRead": "全部标记已读", + "noNotifications": "暂无通知", + "justNow": "刚刚", + "minutesAgo": "{{count}}分钟前", + "hoursAgo": "{{count}}小时前", + "daysAgo": "{{count}}天前", + "viewAll": "查看所有工单", + "newNotification": "新通知", + "clickToView": "点击查看", + "newTicket": "新工单:{{title}}", + "newReply": "工单新回复:{{title}}", + "newTicketTitle": "新工单", + "newReplyTitle": "新回复", + "newUserReply": "用户回复了工单:{{title}}", + "newUserReplyTitle": "用户回复" + }, "auth": { "login": "登录", "register": "注册", diff --git a/src/main.tsx b/src/main.tsx index 53f0e75..6297c78 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import { ThemeColorsProvider } from './providers/ThemeColorsProvider' +import { ToastProvider } from './components/Toast' import './i18n' import './styles/globals.css' @@ -21,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 0a25598..b702355 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { const step = editedApp[stepKey] || emptyAppStep() const buttons = step.buttons || [] - updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) + // Generate unique id for React key + const newButton: AppButton = { + id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + buttonLink: '', + buttonText: emptyLocalizedText() + } + updateButtons(stepKey, [...buttons, newButton]) } const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => { @@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
{buttons.map((button, index) => ( -
+
{t('admin.apps.button')} #{index + 1} +
+
+ + {/* Decorative elements */} +
+
+
+
+
) } diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 6a8e6c9..4815ecc 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom' import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin' import { useCurrency } from '../hooks/useCurrency' -// Icons +// Icons - styled like main navigation const BackIcon = () => ( @@ -12,26 +12,45 @@ const BackIcon = () => ( ) const ServerIcon = () => ( - - + + ) -const UsersIcon = () => ( - - +const UsersOnlineIcon = () => ( + + ) -const CurrencyIcon = () => ( - - +const WalletIcon = () => ( + + ) -const SubscriptionIcon = () => ( - - +const ChartBarIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CubeIcon = () => ( + + + +) + +const TagIcon = () => ( + + + ) @@ -190,14 +209,14 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[ return (
- {last7Days.map((item, index) => { + {last7Days.map((item) => { const percentage = (item.amount_rubles / maxValue) * 100 const date = new Date(item.date) const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' }) const dayNum = date.getDate() return ( -
+
{dayName}, {dayNum} {formatAmount(item.amount_rubles)} {currencySymbol} @@ -319,26 +338,26 @@ export default function AdminDashboard() { } + icon={} color="success" /> } + icon={} color="accent" /> } + icon={} color="warning" /> } + icon={} color="info" />
@@ -347,7 +366,9 @@ export default function AdminDashboard() {
- +
+ +

{t('adminDashboard.nodes.title')}

@@ -395,7 +416,9 @@ export default function AdminDashboard() { {/* Revenue Chart */}

- +
+ +

{t('adminDashboard.revenue.title')}

{t('adminDashboard.revenue.last7Days')}

@@ -417,7 +440,9 @@ export default function AdminDashboard() { {/* Subscription Stats */}
- +
+ +

{t('adminDashboard.subscriptions.title')}

{t('adminDashboard.subscriptions.subtitle')}

@@ -478,7 +503,9 @@ export default function AdminDashboard() { {stats?.servers && (
- +
+ +

{t('adminDashboard.servers.title')}

@@ -506,7 +533,9 @@ export default function AdminDashboard() { {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
- +
+ +

{t('adminDashboard.tariffs.title')}

{t('adminDashboard.tariffs.subtitle')}

diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index ab10f97..3708998 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,339 +1,365 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' -// Icons - smaller versions for mobile -const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - +// Group header icons +const AnalyticsGroupIcon = () => ( + + ) -const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const UsersGroupIcon = () => ( + + + +) + +const TariffsGroupIcon = () => ( + + + +) + +const MarketingGroupIcon = () => ( + + + +) + +const SystemGroupIcon = () => ( + ) -const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +// Modern icons with consistent styling +const ChartBarIcon = () => ( + ) -const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const BanknotesIcon = () => ( + ) -const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const UsersIcon = () => ( + + + +) + +const ChatBubbleIcon = () => ( + + + +) + +const NoSymbolIcon = () => ( + + + +) + +const CreditCardIcon = () => ( + + + +) + +const TicketIcon = () => ( + + + +) + +const GiftIcon = () => ( + ) -const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const MegaphoneIcon = () => ( + + + +) + +const PaperAirplaneIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CogIcon = () => ( + + + + +) + +const DevicePhoneMobileIcon = () => ( + + + +) + +const ServerStackIcon = () => ( + ) +const CubeTransparentIcon = () => ( + + + +) + const ChevronRightIcon = () => ( - + ) -interface AdminSection { +interface AdminItem { to: string icon: React.ReactNode - mobileIcon: React.ReactNode title: string description: string - color: string - bgColor: string - textColor: string } -// Mobile compact card -function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) { +interface AdminGroup { + id: string + title: string + icon: React.ReactNode + gradient: string + borderColor: string + iconBg: string + iconColor: string + items: AdminItem[] +} + +function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) { return ( -
-
- {mobileIcon} -
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+
- {title} ) } -// Desktop card -function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) { +function GroupSection({ group }: { group: AdminGroup }) { return ( - -
-
- {icon} +
+ {/* 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)} /> ) : (