Merge pull request #36 from BEDOLAGA-DEV/main

w
This commit is contained in:
Egor
2026-01-19 07:56:38 +03:00
committed by GitHub
32 changed files with 1618 additions and 434 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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 <Layout>{children}</Layout>
}
// Suspense wrapper for lazy components
function LazyPage({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<PageLoader variant="dark" />}>
{children}
</Suspense>
)
}
function App() {
return (
<Routes>
@@ -90,7 +106,7 @@ function App() {
path="/"
element={
<ProtectedRoute>
<Dashboard />
<LazyPage><Dashboard /></LazyPage>
</ProtectedRoute>
}
/>
@@ -98,7 +114,7 @@ function App() {
path="/subscription"
element={
<ProtectedRoute>
<Subscription />
<LazyPage><Subscription /></LazyPage>
</ProtectedRoute>
}
/>
@@ -106,7 +122,7 @@ function App() {
path="/balance"
element={
<ProtectedRoute>
<Balance />
<LazyPage><Balance /></LazyPage>
</ProtectedRoute>
}
/>
@@ -114,7 +130,7 @@ function App() {
path="/referral"
element={
<ProtectedRoute>
<Referral />
<LazyPage><Referral /></LazyPage>
</ProtectedRoute>
}
/>
@@ -122,7 +138,7 @@ function App() {
path="/support"
element={
<ProtectedRoute>
<Support />
<LazyPage><Support /></LazyPage>
</ProtectedRoute>
}
/>
@@ -130,7 +146,7 @@ function App() {
path="/profile"
element={
<ProtectedRoute>
<Profile />
<LazyPage><Profile /></LazyPage>
</ProtectedRoute>
}
/>
@@ -138,7 +154,7 @@ function App() {
path="/contests"
element={
<ProtectedRoute>
<Contests />
<LazyPage><Contests /></LazyPage>
</ProtectedRoute>
}
/>
@@ -146,7 +162,7 @@ function App() {
path="/polls"
element={
<ProtectedRoute>
<Polls />
<LazyPage><Polls /></LazyPage>
</ProtectedRoute>
}
/>
@@ -154,7 +170,7 @@ function App() {
path="/info"
element={
<ProtectedRoute>
<Info />
<LazyPage><Info /></LazyPage>
</ProtectedRoute>
}
/>
@@ -162,7 +178,7 @@ function App() {
path="/wheel"
element={
<ProtectedRoute>
<Wheel />
<LazyPage><Wheel /></LazyPage>
</ProtectedRoute>
}
/>
@@ -172,7 +188,7 @@ function App() {
path="/admin"
element={
<AdminRoute>
<AdminPanel />
<LazyPage><AdminPanel /></LazyPage>
</AdminRoute>
}
/>
@@ -180,7 +196,7 @@ function App() {
path="/admin/tickets"
element={
<AdminRoute>
<AdminTickets />
<LazyPage><AdminTickets /></LazyPage>
</AdminRoute>
}
/>
@@ -188,7 +204,7 @@ function App() {
path="/admin/settings"
element={
<AdminRoute>
<AdminSettings />
<LazyPage><AdminSettings /></LazyPage>
</AdminRoute>
}
/>
@@ -196,7 +212,7 @@ function App() {
path="/admin/apps"
element={
<AdminRoute>
<AdminApps />
<LazyPage><AdminApps /></LazyPage>
</AdminRoute>
}
/>
@@ -204,7 +220,7 @@ function App() {
path="/admin/wheel"
element={
<AdminRoute>
<AdminWheel />
<LazyPage><AdminWheel /></LazyPage>
</AdminRoute>
}
/>
@@ -212,7 +228,7 @@ function App() {
path="/admin/tariffs"
element={
<AdminRoute>
<AdminTariffs />
<LazyPage><AdminTariffs /></LazyPage>
</AdminRoute>
}
/>
@@ -220,7 +236,7 @@ function App() {
path="/admin/servers"
element={
<AdminRoute>
<AdminServers />
<LazyPage><AdminServers /></LazyPage>
</AdminRoute>
}
/>
@@ -228,7 +244,7 @@ function App() {
path="/admin/dashboard"
element={
<AdminRoute>
<AdminDashboard />
<LazyPage><AdminDashboard /></LazyPage>
</AdminRoute>
}
/>
@@ -236,7 +252,7 @@ function App() {
path="/admin/ban-system"
element={
<AdminRoute>
<AdminBanSystem />
<LazyPage><AdminBanSystem /></LazyPage>
</AdminRoute>
}
/>
@@ -244,7 +260,7 @@ function App() {
path="/admin/broadcasts"
element={
<AdminRoute>
<AdminBroadcasts />
<LazyPage><AdminBroadcasts /></LazyPage>
</AdminRoute>
}
/>
@@ -252,7 +268,7 @@ function App() {
path="/admin/promocodes"
element={
<AdminRoute>
<AdminPromocodes />
<LazyPage><AdminPromocodes /></LazyPage>
</AdminRoute>
}
/>
@@ -260,7 +276,7 @@ function App() {
path="/admin/campaigns"
element={
<AdminRoute>
<AdminCampaigns />
<LazyPage><AdminCampaigns /></LazyPage>
</AdminRoute>
}
/>
@@ -268,7 +284,7 @@ function App() {
path="/admin/users"
element={
<AdminRoute>
<AdminUsers />
<LazyPage><AdminUsers /></LazyPage>
</AdminRoute>
}
/>
@@ -276,7 +292,7 @@ function App() {
path="/admin/payments"
element={
<AdminRoute>
<AdminPayments />
<LazyPage><AdminPayments /></LazyPage>
</AdminRoute>
}
/>
@@ -284,7 +300,7 @@ function App() {
path="/admin/promo-offers"
element={
<AdminRoute>
<AdminPromoOffers />
<LazyPage><AdminPromoOffers /></LazyPage>
</AdminRoute>
}
/>
@@ -292,7 +308,7 @@ function App() {
path="/admin/remnawave"
element={
<AdminRoute>
<AdminRemnawave />
<LazyPage><AdminRemnawave /></LazyPage>
</AdminRoute>
}
/>

View File

@@ -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 {

View File

@@ -9,6 +9,7 @@ export interface LocalizedText {
}
export interface AppButton {
id?: string // Unique identifier for React key (client-side only)
buttonLink: string
buttonText: LocalizedText
}

View File

@@ -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<TicketNotificationList> => {
const response = await apiClient.get('/cabinet/tickets/notifications', {
params: { unread_only: unreadOnly, limit, offset }
})
return response.data
},
getUnreadCount: async (): Promise<UnreadCountResponse> => {
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<TicketNotificationList> => {
const params: Record<string, unknown> = { limit, offset }
if (unreadOnly) {
params.unread_only = true
}
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
return response.data
},
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
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

View File

@@ -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<WheelPrizeAdmin> => {
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
return response.data
},

View File

@@ -81,14 +81,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
<div className="flex items-center gap-3">
{/* Color preview button */}
{/* Color preview button - min 44px for touch accessibility */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className="w-10 h-10 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
className="w-11 h-11 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
style={{ backgroundColor: localValue || '#000000' }}
title={localValue}
aria-label={`Select color: ${localValue}`}
/>
{/* Hex input */}
@@ -112,13 +113,14 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
className="sr-only"
/>
{/* Native picker button */}
{/* Native picker button - min 44px for touch accessibility */}
<button
type="button"
onClick={() => colorInputRef.current?.click()}
disabled={disabled}
className="btn-secondary p-2 disabled:opacity-50"
className="btn-secondary min-w-[44px] min-h-[44px] p-2.5 disabled:opacity-50"
title="Open color picker"
aria-label="Open color picker"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
@@ -134,16 +136,17 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{isOpen && (
<div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in">
<div className="text-xs text-dark-400 mb-2">Preset colors</div>
<div className="grid grid-cols-6 gap-2">
<div className="grid grid-cols-4 sm:grid-cols-6 gap-1">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`w-7 h-7 rounded-lg border-2 transition-all hover:scale-110 ${
localValue === preset ? 'border-white' : 'border-dark-600 hover:border-dark-500'
className={`min-w-[44px] min-h-[44px] w-11 h-11 rounded-lg border-2 transition-all hover:scale-105 ${
localValue === preset ? 'border-white ring-2 ring-white/30' : 'border-dark-600 hover:border-dark-500'
}`}
style={{ backgroundColor: preset }}
title={preset}
aria-label={`Select color ${preset}`}
/>
))}
</div>

View File

@@ -286,7 +286,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<button onClick={onClose} className="btn-icon" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

View File

@@ -145,14 +145,14 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
const { formatAmount, currencySymbol } = useCurrency()
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4">
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
<div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-[80vh] flex flex-col">
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

View File

@@ -179,9 +179,9 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
>
{/* Progress indicator */}
<div className="flex items-center gap-1.5 mb-4">
{steps.map((_, index) => (
{steps.map((s, index) => (
<div
key={index}
key={s.target}
className={`h-1 rounded-full transition-all duration-300 ${
index === currentStep
? 'w-6 bg-accent-500'

View File

@@ -0,0 +1,288 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ticketNotificationsApi } from '../api/ticketNotifications'
import { useAuthStore } from '../store/auth'
import { useToast } from './Toast'
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
import type { TicketNotification } from '../types'
const BellIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)
interface TicketNotificationBellProps {
isAdmin?: boolean
}
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAuthenticated } = useAuthStore()
const { showToast } = useToast()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Show toast for WebSocket notification
const showWSNotificationToast = useCallback((message: WSMessage) => {
const isNewTicket = message.type === 'ticket.new'
const isAdminReply = message.type === 'ticket.admin_reply'
const isUserReply = message.type === 'ticket.user_reply'
const icon = isNewTicket ? (
<span className="text-lg">🎫</span>
) : isAdminReply ? (
<span className="text-lg">💬</span>
) : (
<span className="text-lg">📨</span>
)
const ticketTitle = message.title || ''
let toastTitle: string
let toastMessage: string
if (isNewTicket) {
toastTitle = t('notifications.newTicketTitle', 'New Ticket')
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle })
} else if (isUserReply) {
toastTitle = t('notifications.newUserReplyTitle', 'User Reply')
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle })
} else {
toastTitle = t('notifications.newReplyTitle', 'New Reply')
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle })
}
showToast({
type: 'info',
title: toastTitle,
message: toastMessage,
icon,
onClick: () => {
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`)
},
duration: 8000,
})
}, [showToast, navigate, isAdmin, t])
// Handle WebSocket message
const handleWSMessage = useCallback((message: WSMessage) => {
// Check if this notification is relevant for this user type
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply'
const isUserNotification = message.type === 'ticket.admin_reply'
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
// Show toast
showWSNotificationToast(message)
// Invalidate queries to refresh count and list
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count']
})
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications']
})
}
}, [isAdmin, showWSNotificationToast, queryClient])
// WebSocket connection
useWebSocket({
onMessage: handleWSMessage,
})
// Fetch unread count (with slower polling as fallback when WS disconnects)
const { data: unreadData } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount,
enabled: isAuthenticated,
refetchInterval: 60000, // Poll every 60 seconds as fallback
staleTime: 30000,
})
// Fetch notifications when dropdown is open
const { data: notificationsData, isLoading } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
queryFn: () => isAdmin
? ticketNotificationsApi.getAdminNotifications(false, 10)
: ticketNotificationsApi.getNotifications(false, 10),
enabled: isAuthenticated && isOpen,
staleTime: 5000,
})
// Mark all as read mutation
const markAllReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
},
})
// Mark single as read mutation
const markReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
},
})
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleNotificationClick = (notification: TicketNotification) => {
if (!notification.is_read) {
markReadMutation.mutate(notification.id)
}
setIsOpen(false)
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return t('notifications.justNow', 'Just now')
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins })
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours })
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays })
}
const getNotificationIcon = (type: string) => {
switch (type) {
case 'new_ticket':
return <span className="text-lg">🎫</span>
case 'admin_reply':
return <span className="text-lg">💬</span>
case 'user_reply':
return <span className="text-lg">📨</span>
default:
return <span className="text-lg">🔔</span>
}
}
const unreadCount = unreadData?.unread_count || 0
return (
<div className="relative" ref={dropdownRef}>
{/* Bell button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2.5 rounded-xl transition-all duration-200 hover:bg-dark-800/50 text-dark-400 hover:text-dark-100"
title={t('notifications.ticketNotifications', 'Ticket notifications')}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1 animate-scale-in-bounce">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className="fixed sm:absolute top-16 sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
<h3 className="text-sm font-semibold text-dark-100">
{t('notifications.ticketNotifications', 'Ticket Notifications')}
</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
>
<CheckIcon />
{t('notifications.markAllRead', 'Mark all read')}
</button>
)}
</div>
{/* Notifications list */}
<div className="max-h-80 overflow-y-auto">
{isLoading ? (
<div className="p-8 text-center text-dark-500">
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
</div>
) : notificationsData?.items && notificationsData.items.length > 0 ? (
notificationsData.items.map((notification: TicketNotification) => (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${
!notification.is_read ? 'bg-accent-500/5' : ''
}`}
>
<div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
{getNotificationIcon(notification.notification_type)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
{notification.message}
</p>
<p className="text-xs text-dark-500 mt-1">
{formatTime(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<div className="flex-shrink-0 pt-1">
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
</div>
)}
</div>
</button>
))
) : (
<div className="p-8 text-center">
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500">
<BellIcon />
</div>
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
</div>
)}
</div>
{/* Footer */}
{notificationsData?.items && notificationsData.items.length > 0 && (
<div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30">
<button
onClick={() => {
setIsOpen(false)
navigate(isAdmin ? '/admin/tickets' : '/support')
}}
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors"
>
{t('notifications.viewAll', 'View all tickets')}
</button>
</div>
)}
</div>
)}
</div>
)
}

210
src/components/Toast.tsx Normal file
View File

@@ -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<ToastContextType | null>(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<Toast[]>([])
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(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 (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
)
}
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: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
}
return (
<div
className={`
pointer-events-auto
w-80 sm:w-96
${style.bg}
backdrop-blur-xl
border ${style.border}
rounded-2xl
shadow-2xl shadow-black/20
overflow-hidden
animate-slide-in-right
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
transition-transform duration-200
`}
onClick={handleClick}
>
{/* Glow effect */}
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
<div className="relative p-4">
<div className="flex gap-3">
{/* Icon */}
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}>
{toast.icon || defaultIcons[toast.type || 'info']}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pt-0.5">
{toast.title && (
<p className="text-sm font-semibold text-dark-100 mb-0.5">
{toast.title}
</p>
)}
<p className="text-sm text-dark-300 leading-relaxed">
{toast.message}
</p>
</div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation()
onClose()
}}
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
<div
className={`h-full ${style.icon.replace('text-', 'bg-')} opacity-60`}
style={{
animation: `shrink ${toast.duration}ms linear forwards`,
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4">
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
<div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{methodName}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>

View File

@@ -5,11 +5,13 @@ import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/auth'
import LanguageSwitcher from '../LanguageSwitcher'
import PromoDiscountBadge from '../PromoDiscountBadge'
import TicketNotificationBell from '../TicketNotificationBell'
import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls'
import { brandingApi } from '../../api/branding'
import { wheelApi } from '../../api/wheel'
import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme'
// Fallback branding from environment variables
@@ -209,6 +211,17 @@ export default function Layout({ children }: LayoutProps) {
retry: false,
})
// Fetch active discount to determine mobile layout
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated,
staleTime: 30000,
})
// Check if promo is active (to hide language switcher on mobile)
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent
const navItems = useMemo(() => {
const items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
@@ -324,6 +337,7 @@ export default function Layout({ children }: LayoutProps) {
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
>
<div className="relative w-5 h-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
@@ -337,7 +351,11 @@ export default function Layout({ children }: LayoutProps) {
)}
<PromoDiscountBadge />
<LanguageSwitcher />
<TicketNotificationBell isAdmin={isAdminActive()} />
{/* Hide language switcher on mobile when promo is active */}
<div className={isPromoActive ? 'hidden sm:block' : ''}>
<LanguageSwitcher />
</div>
{/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3">
@@ -356,6 +374,7 @@ export default function Layout({ children }: LayoutProps) {
onClick={logout}
className="btn-icon"
title={t('nav.logout')}
aria-label={t('nav.logout') || 'Logout'}
>
<LogoutIcon />
</button>
@@ -365,6 +384,8 @@ export default function Layout({ children }: LayoutProps) {
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
@@ -387,29 +408,33 @@ export default function Layout({ children }: LayoutProps) {
<div className="absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom,0px))]">
<div className="max-w-6xl mx-auto px-4 py-4">
{/* User info */}
<div className="flex items-center gap-3 pb-4 mb-4 border-b border-dark-800/50">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
<UserIcon />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
<div className="flex items-center gap-3">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
<UserIcon />
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>
</div>
</div>
{/* Language switcher in mobile menu when promo is active */}
{isPromoActive && <LanguageSwitcher />}
</div>
{/* Nav items */}
@@ -478,6 +503,7 @@ export default function Layout({ children }: LayoutProps) {
<div className="animate-fade-in">
{children}
</div>
</main>
{/* Mobile Bottom Navigation - only core items */}

156
src/hooks/useWebSocket.ts Normal file
View File

@@ -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<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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 }
}

View File

@@ -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",

View File

@@ -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": "ثبت نام",

View File

@@ -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": "Управление тарифами",

View File

@@ -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": "注册",

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeColorsProvider>
<App />
<ToastProvider>
<App />
</ToastProvider>
</ThemeColorsProvider>
</BrowserRouter>
</QueryClientProvider>

View File

@@ -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
</button>
</div>
{buttons.map((button, index) => (
<div key={index} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
<div key={button.id || `fallback-${index}`} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span>
<button

View File

@@ -384,9 +384,75 @@ export default function AdminBanSystem() {
if (error && !status?.enabled) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-error-400">{error}</div>
<p className="text-dark-400 text-sm">{t('banSystem.configureHint')}</p>
<div className="min-h-[60vh] flex items-center justify-center animate-fade-in">
<div className="max-w-md w-full mx-4">
{/* Card */}
<div className="bg-dark-800/50 backdrop-blur-xl rounded-2xl border border-dark-700 p-8 text-center shadow-2xl">
{/* Icon */}
<div className="mb-6 flex justify-center">
<div className="relative">
<div className="w-20 h-20 bg-gradient-to-br from-error-500/20 to-warning-500/20 rounded-2xl flex items-center justify-center">
<svg className="w-10 h-10 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-dark-800 rounded-full flex items-center justify-center border border-dark-600">
<svg className="w-3.5 h-3.5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
</svg>
</div>
</div>
</div>
{/* Title */}
<h2 className="text-xl font-bold text-dark-100 mb-2">
{t('banSystem.title')}
</h2>
{/* Error message */}
<p className="text-error-400 font-medium mb-2">
{error}
</p>
{/* Hint */}
<p className="text-dark-400 text-sm mb-8">
{t('banSystem.configureHint')}
</p>
{/* Buttons */}
<div className="flex flex-col gap-3">
{/* Telegram Button */}
<a
href="https://t.me/fringg"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-3 w-full py-3 px-4 bg-gradient-to-r from-[#0088cc] to-[#0099dd] hover:from-[#0077bb] hover:to-[#0088cc] text-white font-medium rounded-xl transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:shadow-[#0088cc]/20"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
{t('banSystem.contactTelegram', { defaultValue: 'Написать в Telegram' })}
</a>
{/* Back Button */}
<button
onClick={() => window.history.back()}
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-dark-700 hover:bg-dark-600 text-dark-200 hover:text-dark-100 font-medium rounded-xl transition-all duration-200 border border-dark-600 hover:border-dark-500"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
{t('common.back', { defaultValue: 'Назад' })}
</button>
</div>
</div>
{/* Decorative elements */}
<div className="absolute inset-0 pointer-events-none overflow-hidden -z-10">
<div className="absolute top-1/4 -left-20 w-40 h-40 bg-accent-500/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 -right-20 w-40 h-40 bg-error-500/5 rounded-full blur-3xl" />
</div>
</div>
</div>
)
}

View File

@@ -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 = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
@@ -12,26 +12,45 @@ const BackIcon = () => (
)
const ServerIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z" />
</svg>
)
const UsersIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
const UsersOnlineIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
)
const CurrencyIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
</svg>
)
const SubscriptionIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
const ChartBarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const CubeIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
)
const TagIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
)
@@ -190,14 +209,14 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
return (
<div className="space-y-3">
{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 (
<div key={index} className="group">
<div key={item.date} className="group">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span>
<span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>
@@ -319,26 +338,26 @@ export default function AdminDashboard() {
<StatCard
title={t('adminDashboard.stats.usersOnline')}
value={stats?.nodes.total_users_online || 0}
icon={<UsersIcon />}
icon={<UsersOnlineIcon />}
color="success"
/>
<StatCard
title={t('adminDashboard.stats.activeSubscriptions')}
value={stats?.subscriptions.active || 0}
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
icon={<SubscriptionIcon />}
icon={<SparklesIcon />}
color="accent"
/>
<StatCard
title={t('adminDashboard.stats.incomeToday')}
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
icon={<WalletIcon />}
color="warning"
/>
<StatCard
title={t('adminDashboard.stats.incomeMonth')}
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
icon={<CurrencyIcon />}
icon={<ChartBarIcon />}
color="info"
/>
</div>
@@ -347,7 +366,9 @@ export default function AdminDashboard() {
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ServerIcon />
<div className="p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
<ServerIcon />
</div>
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.nodes.title')}</h2>
<p className="text-sm text-dark-400">
@@ -395,7 +416,9 @@ export default function AdminDashboard() {
{/* Revenue Chart */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<CurrencyIcon />
<div className="p-2.5 rounded-lg bg-warning-500/20 text-warning-400">
<ChartBarIcon />
</div>
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.revenue.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.revenue.last7Days')}</p>
@@ -417,7 +440,9 @@ export default function AdminDashboard() {
{/* Subscription Stats */}
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<SubscriptionIcon />
<div className="p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
<SparklesIcon />
</div>
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.subscriptions.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.subscriptions.subtitle')}</p>
@@ -478,7 +503,9 @@ export default function AdminDashboard() {
{stats?.servers && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<ServerIcon />
<div className="p-2.5 rounded-lg bg-info-500/20 text-info-400">
<CubeIcon />
</div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.servers.title')}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
@@ -506,7 +533,9 @@ export default function AdminDashboard() {
{stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
<div className="flex items-center gap-3 mb-4">
<SubscriptionIcon />
<div className="p-2.5 rounded-lg bg-success-500/20 text-success-400">
<TagIcon />
</div>
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.tariffs.title')}</h2>
<p className="text-sm text-dark-400">{t('adminDashboard.tariffs.subtitle')}</p>

View File

@@ -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 }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
// Group header icons
const AnalyticsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
</svg>
)
const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const UsersGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
)
const TariffsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
const MarketingGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
</svg>
)
const SystemGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
// Modern icons with consistent styling
const ChartBarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
)
const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
</svg>
)
const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</svg>
)
const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const BanknotesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
)
const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const ChatBubbleIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
)
const NoSymbolIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
)
const CreditCardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const TicketIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)
const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const MegaphoneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
</svg>
)
const PaperAirplaneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const DevicePhoneMobileIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const ServerStackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const CubeTransparentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-4 h-4 text-dark-500 group-hover:text-dark-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
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 (
<Link
to={to}
className="flex flex-col items-center justify-center p-3 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 active:scale-95 transition-all duration-150"
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200"
>
<div className={`w-12 h-12 rounded-xl ${bgColor} flex items-center justify-center mb-2`}>
<div className={textColor}>
{mobileIcon}
</div>
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
{icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
<p className="text-xs text-dark-500 truncate">{description}</p>
</div>
<div className="text-dark-600 group-hover:text-dark-400 transition-colors">
<ChevronRightIcon />
</div>
<span className="text-xs font-medium text-dark-200 text-center leading-tight">{title}</span>
</Link>
)
}
// Desktop card
function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) {
function GroupSection({ group }: { group: AdminGroup }) {
return (
<Link
to={to}
className="group flex items-center gap-4 p-4 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 hover:bg-dark-800 transition-all duration-200"
>
<div className={`w-14 h-14 rounded-xl ${bgColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
<div className={textColor}>
{icon}
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur">
{/* Group Header */}
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}>
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
{group.icon}
</div>
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-dark-100 mb-0.5">{title}</h3>
<p className="text-sm text-dark-400 truncate">{description}</p>
{/* Group Items */}
<div className="p-2 space-y-1.5">
{group.items.map((item) => (
<AdminCard
key={item.to}
{...item}
iconBg={group.iconBg}
iconColor={group.iconColor}
/>
))}
</div>
<ChevronRightIcon />
</Link>
</div>
)
}
export default function AdminPanel() {
const { t } = useTranslation()
const adminSections: AdminSection[] = [
const groups: AdminGroup[] = [
{
to: '/admin/dashboard',
icon: <ChartIcon />,
mobileIcon: <ChartIcon className="w-6 h-6" />,
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: <AnalyticsGroupIcon />,
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: <ChartBarIcon />,
title: t('admin.nav.dashboard'),
description: t('admin.panel.dashboardDesc'),
},
{
to: '/admin/payments',
icon: <BanknotesIcon />,
title: t('admin.nav.payments', 'Платежи'),
description: t('admin.panel.paymentsDesc', 'История и проверка платежей'),
},
],
},
{
to: '/admin/tickets',
icon: <TicketIcon />,
mobileIcon: <TicketIcon className="w-6 h-6" />,
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: <UsersGroupIcon />,
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: <UsersIcon />,
title: t('admin.nav.users', 'Пользователи'),
description: t('admin.panel.usersDesc', 'Управление пользователями'),
},
{
to: '/admin/tickets',
icon: <ChatBubbleIcon />,
title: t('admin.nav.tickets'),
description: t('admin.panel.ticketsDesc'),
},
{
to: '/admin/ban-system',
icon: <NoSymbolIcon />,
title: t('admin.nav.banSystem'),
description: t('admin.panel.banSystemDesc'),
},
],
},
{
to: '/admin/settings',
icon: <CogIcon />,
mobileIcon: <CogIcon className="w-6 h-6" />,
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: <TariffsGroupIcon />,
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: <CreditCardIcon />,
title: t('admin.nav.tariffs'),
description: t('admin.panel.tariffsDesc'),
},
{
to: '/admin/promocodes',
icon: <TicketIcon />,
title: t('admin.nav.promocodes', 'Промокоды'),
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
},
{
to: '/admin/promo-offers',
icon: <GiftIcon />,
title: t('admin.nav.promoOffers', 'Промопредложения'),
description: t('admin.panel.promoOffersDesc', 'Персональные скидки'),
},
],
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
mobileIcon: <PhoneIcon className="w-6 h-6" />,
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: <MarketingGroupIcon />,
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: <MegaphoneIcon />,
title: t('admin.nav.campaigns', 'Кампании'),
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
},
{
to: '/admin/broadcasts',
icon: <PaperAirplaneIcon />,
title: t('admin.nav.broadcasts'),
description: t('admin.panel.broadcastsDesc'),
},
{
to: '/admin/wheel',
icon: <SparklesIcon />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
},
],
},
{
to: '/admin/wheel',
icon: <WheelIcon />,
mobileIcon: <WheelIcon className="w-6 h-6" />,
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: <TariffIcon />,
mobileIcon: <TariffIcon className="w-6 h-6" />,
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: <ServerIcon />,
mobileIcon: <ServerIcon className="w-6 h-6" />,
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: <BroadcastIcon />,
mobileIcon: <BroadcastIcon className="w-6 h-6" />,
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: <PromocodeIcon />,
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
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: <PromoOffersIcon />,
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
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: <CampaignIcon />,
mobileIcon: <CampaignIcon className="w-6 h-6" />,
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: <UsersIcon />,
mobileIcon: <UsersIcon className="w-6 h-6" />,
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: <BanSystemIcon />,
mobileIcon: <BanSystemIcon className="w-6 h-6" />,
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: <PaymentsIcon />,
mobileIcon: <PaymentsIcon className="w-6 h-6" />,
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: <RemnawaveIcon />,
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
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: <SystemGroupIcon />,
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: <CogIcon />,
title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
},
{
to: '/admin/apps',
icon: <DevicePhoneMobileIcon />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
},
{
to: '/admin/servers',
icon: <ServerStackIcon />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
},
{
to: '/admin/remnawave',
icon: <CubeTransparentIcon />,
title: t('admin.nav.remnawave', 'RemnaWave'),
description: t('admin.panel.remnawaveDesc', 'Управление панелью'),
},
],
},
]
return (
<div className="animate-fade-in">
{/* Header - compact on mobile */}
<div className="flex items-center gap-3 mb-6">
<div className="p-2.5 sm:p-3 bg-amber-500/20 rounded-xl">
<AdminIcon className="w-6 h-6 sm:w-8 sm:h-8 text-amber-400" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-sm text-dark-400 hidden sm:block">{t('admin.panel.subtitle')}</p>
</div>
<div className="animate-fade-in space-y-4">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p>
</div>
{/* Mobile: Compact 2-column grid */}
<div className="grid grid-cols-3 gap-3 sm:hidden">
{adminSections.map((section) => (
<MobileAdminCard key={section.to} {...section} />
))}
</div>
{/* Tablet/Desktop: List style */}
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
{adminSections.map((section) => (
<DesktopAdminCard key={section.to} {...section} />
{/* Groups Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{groups.map((group) => (
<GroupSection key={group.id} group={group} />
))}
</div>
</div>

View File

@@ -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 }) {
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
</div>
{/* Cabinet Notifications */}
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.cabinetNotifications')}</h3>
{/* User Notifications */}
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.cabinet_user_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_user_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.userNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.userNotificationsEnabledDesc')}</div>
</div>
</label>
</div>
{/* Admin Notifications */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.cabinet_admin_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_admin_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.adminNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.adminNotificationsEnabledDesc')}</div>
</div>
</label>
</div>
</div>
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.slaSettings')}</h3>

View File

@@ -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)
}
}}
/>

View File

@@ -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<Status>('countdown')
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
const [copied, setCopied] = useState(false)
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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

View File

@@ -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)
}

View File

@@ -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)
<button
className="absolute top-4 right-4 text-white/70 hover:text-white"
onClick={() => setShowFullImage(false)}
aria-label="Close fullscreen"
>
<CloseIcon />
</button>
@@ -141,6 +142,29 @@ export default function Support() {
const createFileInputRef = useRef<HTMLInputElement>(null)
const replyFileInputRef = useRef<HTMLInputElement>(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 ? (
<AttachmentPreview
attachment={createAttachment}
onRemove={() => setCreateAttachment(null)}
onRemove={() => clearAttachment(createAttachment, setCreateAttachment)}
/>
) : (
<button
@@ -604,7 +634,7 @@ export default function Support() {
type="button"
onClick={() => {
setShowCreateForm(false)
setCreateAttachment(null)
clearAttachment(createAttachment, setCreateAttachment)
}}
className="btn-secondary"
>
@@ -704,14 +734,14 @@ export default function Support() {
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file, setReplyAttachment)
if (file) handleFileSelect(file, setReplyAttachment, replyAttachment)
e.target.value = ''
}}
/>
{replyAttachment ? (
<AttachmentPreview
attachment={replyAttachment}
onRemove={() => setReplyAttachment(null)}
onRemove={() => clearAttachment(replyAttachment, setReplyAttachment)}
/>
) : (
<button

View File

@@ -1044,3 +1044,9 @@
.animate-wheel-glow {
animation: wheel-glow 2s ease-in-out infinite;
}
/* Toast progress bar animation */
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}

View File

@@ -453,6 +453,7 @@ export interface LocalizedText {
}
export interface AppButton {
id?: string // Unique identifier for React key (client-side only)
buttonLink: string
buttonText: LocalizedText
}
@@ -516,3 +517,33 @@ export interface ManualCheckResponse {
old_status: string | null
new_status: string | null
}
// Ticket notifications types
export interface TicketNotification {
id: number
ticket_id: number
notification_type: 'new_ticket' | 'admin_reply' | 'user_reply'
message: string | null
is_read: boolean
created_at: string
read_at: string | null
}
export interface TicketNotificationList {
items: TicketNotification[]
unread_count: number
}
export interface UnreadCountResponse {
unread_count: number
}
export interface TicketSettings {
sla_enabled: boolean
sla_minutes: number
sla_check_interval_seconds: number
sla_reminder_cooldown_minutes: number
support_system_mode: string
cabinet_user_notifications_enabled: boolean
cabinet_admin_notifications_enabled: boolean
}