Merge pull request #8 from PEDZEO/main

fix
This commit is contained in:
PEDZEO
2026-01-20 02:03:26 +03:00
committed by GitHub
34 changed files with 2133 additions and 739 deletions

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

@@ -153,6 +153,15 @@ export interface NodeStatus {
users_online: number
traffic_used_bytes?: number
uptime?: string
xray_version?: string
node_version?: string
last_status_message?: string
xray_uptime?: string
is_xray_running?: boolean
cpu_count?: number
cpu_model?: string
total_ram?: string
country_code?: string
}
export interface NodesOverview {
@@ -225,6 +234,75 @@ export interface DashboardStats {
tariff_stats?: TariffStats
}
// ============ Extended Stats Types ============
export interface TopReferrerItem {
user_id: number
telegram_id: number
username?: string
display_name: string
invited_count: number
invited_today: number
invited_week: number
invited_month: number
earnings_today_kopeks: number
earnings_week_kopeks: number
earnings_month_kopeks: number
earnings_total_kopeks: number
}
export interface TopReferrersResponse {
by_earnings: TopReferrerItem[]
by_invited: TopReferrerItem[]
total_referrers: number
total_referrals: number
total_earnings_kopeks: number
}
export interface TopCampaignItem {
id: number
name: string
start_parameter: string
bonus_type: string
is_active: boolean
registrations: number
conversions: number
conversion_rate: number
total_revenue_kopeks: number
avg_revenue_per_user_kopeks: number
created_at?: string
}
export interface TopCampaignsResponse {
campaigns: TopCampaignItem[]
total_campaigns: number
total_registrations: number
total_revenue_kopeks: number
}
export interface RecentPaymentItem {
id: number
user_id: number
telegram_id: number
username?: string
display_name: string
amount_kopeks: number
amount_rubles: number
type: string
type_display: string
payment_method?: string
description?: string
created_at: string
is_completed: boolean
}
export interface RecentPaymentsResponse {
payments: RecentPaymentItem[]
total_count: number
total_today_kopeks: number
total_week_kopeks: number
}
// ============ Dashboard Stats API ============
export const statsApi = {
@@ -251,4 +329,22 @@ export const statsApi = {
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`)
return response.data
},
// Get top referrers
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } })
return response.data
},
// Get top campaigns
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } })
return response.data
},
// Get recent payments
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } })
return response.data
},
}

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

@@ -7,6 +7,70 @@ export interface BrandingInfo {
has_custom_logo: boolean
}
export interface AnimationEnabled {
enabled: boolean
}
const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
// Get cached branding from localStorage
export const getCachedBranding = (): BrandingInfo | null => {
try {
const cached = localStorage.getItem(BRANDING_CACHE_KEY)
if (cached) {
return JSON.parse(cached)
}
} catch {
// localStorage not available or invalid JSON
}
return null
}
// Update branding cache in localStorage
export const setCachedBranding = (branding: BrandingInfo) => {
try {
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding))
} catch {
// localStorage not available
}
}
// Preload logo image for instant display
export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
return new Promise((resolve) => {
if (!branding.has_custom_logo || !branding.logo_url) {
resolve()
return
}
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
// Check if already preloaded in this session
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
if (preloaded === logoUrl) {
resolve()
return
}
const img = new Image()
img.onload = () => {
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl)
resolve()
}
img.onerror = () => resolve()
img.src = logoUrl
})
}
// Initialize logo preload from cache on page load
export const initLogoPreload = () => {
const cached = getCachedBranding()
if (cached) {
preloadLogo(cached)
}
}
export const brandingApi = {
// Get current branding (public, no auth required)
getBranding: async (): Promise<BrandingInfo> => {
@@ -45,4 +109,16 @@ export const brandingApi = {
}
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
},
// Get animation enabled (public, no auth required)
getAnimationEnabled: async (): Promise<AnimationEnabled> => {
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation')
return response.data
},
// Update animation enabled (admin only)
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
return response.data
},
}

View File

@@ -2,7 +2,7 @@ import apiClient from './client'
// ============== Types ==============
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group'
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount'
export interface PromoCode {
id: number

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

@@ -0,0 +1,86 @@
import { useEffect, useState, memo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
// Detect low-performance device (mobile in Telegram WebApp)
const isLowPerformance = (): boolean => {
// Check if running in Telegram WebApp
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
// Check if mobile
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
return prefersReducedMotion || (isTelegramWebApp && isMobile)
}
// Get cached value from localStorage
const getCachedAnimationEnabled = (): boolean | null => {
try {
const cached = localStorage.getItem(ANIMATION_CACHE_KEY)
if (cached !== null) {
return cached === 'true'
}
} catch {
// localStorage not available
}
return null
}
// Update cache in localStorage
export const setCachedAnimationEnabled = (enabled: boolean) => {
try {
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled))
} catch {
// localStorage not available
}
}
// Memoized background component to prevent re-renders
const AnimatedBackground = memo(function AnimatedBackground() {
// Start with cached value (null means unknown yet)
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
const [isLowPerf] = useState(() => isLowPerformance())
const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled,
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
retry: false,
})
// Update state and cache when data arrives
useEffect(() => {
if (animationSettings !== undefined) {
const enabled = animationSettings.enabled
setIsEnabled(enabled)
setCachedAnimationEnabled(enabled)
}
}, [animationSettings])
// Don't render if disabled or on low-performance devices
if (isEnabled !== true || isLowPerf) {
return null
}
// Render only 2 blobs on mobile for better performance
const isMobile = window.innerWidth < 768
return (
<div className="wave-bg-container">
<div className="wave-blob wave-blob-1" />
<div className="wave-blob wave-blob-2" />
{!isMobile && (
<>
<div className="wave-blob wave-blob-3" />
<div className="wave-blob wave-blob-4" />
</>
)}
</div>
)
})
export default AnimatedBackground

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
interface ColorPickerProps {
value: string
@@ -8,6 +8,28 @@ interface ColorPickerProps {
disabled?: boolean
}
// Check if running in Telegram WebApp (native color picker causes crash)
const isTelegramWebApp = (): boolean => {
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
}
// Convert hex to RGB
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 }
}
// Convert RGB to hex
const rgbToHex = (r: number, g: number, b: number): string => {
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
}
const PRESET_COLORS = [
'#3b82f6', // Blue
'#ef4444', // Red
@@ -21,18 +43,36 @@ const PRESET_COLORS = [
'#f97316', // Orange
'#6366f1', // Indigo
'#a855f7', // Purple
'#ffffff', // White
'#64748b', // Slate
'#1e293b', // Dark
'#000000', // Black
]
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [localValue, setLocalValue] = useState(value)
const [rgb, setRgb] = useState(() => hexToRgb(value))
const containerRef = useRef<HTMLDivElement>(null)
const colorInputRef = useRef<HTMLInputElement>(null)
// Memoize Telegram check to avoid recalculating
const isTelegram = useMemo(() => isTelegramWebApp(), [])
useEffect(() => {
setLocalValue(value)
setRgb(hexToRgb(value))
}, [value])
// Handle RGB slider change
const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => {
const newRgb = { ...rgb, [channel]: val }
setRgb(newRgb)
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b)
setLocalValue(hex)
onChange(hex)
}, [rgb, onChange])
// Close on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -81,14 +121,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 */}
@@ -102,48 +143,111 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
maxLength={7}
/>
{/* Hidden native color picker for direct access */}
<input
ref={colorInputRef}
type="color"
value={localValue || '#000000'}
onChange={handleColorInputChange}
disabled={disabled}
className="sr-only"
/>
{/* Native picker button */}
<button
type="button"
onClick={() => colorInputRef.current?.click()}
disabled={disabled}
className="btn-secondary p-2 disabled:opacity-50"
title="Open color picker"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
{/* Native color picker - hidden in Telegram WebApp (causes crash) */}
{!isTelegram && (
<>
<input
ref={colorInputRef}
type="color"
value={localValue || '#000000'}
onChange={handleColorInputChange}
disabled={disabled}
className="sr-only"
/>
</svg>
</button>
{/* Native picker button - min 44px for touch accessibility */}
<button
type="button"
onClick={() => colorInputRef.current?.click()}
disabled={disabled}
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
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
</button>
</>
)}
</div>
{/* Dropdown with presets */}
{/* Dropdown with presets and RGB sliders */}
{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="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in w-72">
{/* RGB Sliders - shown in Telegram instead of native picker */}
{isTelegram && (
<div className="mb-3 pb-3 border-b border-dark-700">
<div className="text-xs text-dark-400 mb-2">RGB</div>
<div className="space-y-2">
{/* Red */}
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 w-4">R</span>
<input
type="range"
min="0"
max="255"
value={rgb.r}
onChange={(e) => handleRgbChange('r', parseInt(e.target.value))}
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.r}</span>
</div>
{/* Green */}
<div className="flex items-center gap-2">
<span className="text-xs text-green-400 w-4">G</span>
<input
type="range"
min="0"
max="255"
value={rgb.g}
onChange={(e) => handleRgbChange('g', parseInt(e.target.value))}
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.g}</span>
</div>
{/* Blue */}
<div className="flex items-center gap-2">
<span className="text-xs text-blue-400 w-4">B</span>
<input
type="range"
min="0"
max="255"
value={rgb.b}
onChange={(e) => handleRgbChange('b', parseInt(e.target.value))}
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.b}</span>
</div>
</div>
</div>
)}
<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 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-full aspect-square rounded-lg border-2 transition-all hover:scale-105 ${
localValue.toLowerCase() === preset.toLowerCase() ? '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

@@ -61,12 +61,8 @@ const platformIconComponents: Record<string, React.FC> = {
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
// Allowed app schemes for deep links
const allowedAppSchemes = [
'happ://', 'flclash://', 'clash://', 'sing-box://', 'v2rayng://',
'sub://', 'shadowrocket://', 'hiddify://', 'streisand://',
'quantumult://', 'surge://', 'loon://', 'nekobox://', 'v2box://'
]
// Dangerous schemes that should never be allowed
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
/**
* Validate URL to prevent XSS via javascript: and other dangerous schemes
@@ -87,20 +83,20 @@ function isValidExternalUrl(url: string | undefined): boolean {
}
/**
* Validate deep link URL - only allows known VPN app schemes
* Validate deep link URL - blocks dangerous schemes only
* Allows any custom app scheme (clash://, hiddify://, etc.)
*/
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Allow known app schemes
return allowedAppSchemes.some(scheme => lowerUrl.startsWith(scheme))
// Allow any URL that has a scheme (contains ://)
return lowerUrl.includes('://')
}
// Detect user's platform from user agent
@@ -227,8 +223,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
if (isCustomScheme && tg?.openLink) {
// For custom URL schemes - open redirect page in external browser via Telegram
// try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных)
try {
tg.openLink(redirectUrl, { try_instant_view: false })
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return
} catch (e) {
console.warn('tg.openLink failed:', e)

View File

@@ -152,7 +152,7 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
{/* 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

@@ -88,19 +88,12 @@ export default function PromoDiscountBadge() {
{/* Badge button */}
<button
onClick={handleClick}
className="relative flex items-center gap-1.5 px-3 py-2 rounded-xl bg-gradient-to-r from-success-500/20 to-accent-500/20 border border-success-500/30 hover:border-success-500/50 transition-all group"
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group"
title={t('promo.activeDiscount', 'Active discount')}
>
{/* Animated sparkle effect */}
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-success-500/10 to-accent-500/10 animate-pulse" />
<SparklesIcon />
<span className="relative font-bold text-success-400 text-sm">
<span className="font-bold text-success-400 text-sm">
-{activeDiscount.discount_percent}%
</span>
{/* Notification dot */}
<span className="absolute -top-1 -right-1 w-2 h-2 bg-success-400 rounded-full animate-pulse" />
</button>
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}

View File

@@ -6,6 +6,7 @@ import { ticketNotificationsApi } from '../api/ticketNotifications'
import { useAuthStore } from '../store/auth'
import { useToast } from './Toast'
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import type { TicketNotification } from '../types'
const BellIcon = () => (
@@ -32,6 +33,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
: 64 // default header height
// Show toast for WebSocket notification
const showWSNotificationToast = useCallback((message: WSMessage) => {
@@ -204,7 +211,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* 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">
<div
className={`fixed sm:absolute 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 ${
!isFullscreen ? 'top-16' : ''
}`}
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
>
{/* 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">

View File

@@ -17,7 +17,8 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
}
if (webApp?.openLink) {
try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) }
// try_browser: true - открывает диалог для перехода во внешний браузер (важно для мобильных)
try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) }
}
if (reservedWindow && !reservedWindow.closed) {
try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) }

View File

@@ -6,12 +6,15 @@ import { useAuthStore } from '../../store/auth'
import LanguageSwitcher from '../LanguageSwitcher'
import PromoDiscountBadge from '../PromoDiscountBadge'
import TicketNotificationBell from '../TicketNotificationBell'
import AnimatedBackground from '../AnimatedBackground'
import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls'
import { brandingApi } from '../../api/branding'
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo } from '../../api/branding'
import { wheelApi } from '../../api/wheel'
import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -120,6 +123,18 @@ const WheelIcon = () => (
</svg>
)
const FullscreenIcon = () => (
<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.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)
const ExitFullscreenIcon = () => (
<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 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
)
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
@@ -127,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
@@ -165,15 +181,26 @@ export default function Layout({ children }: LayoutProps) {
}
}, [mobileMenuOpen])
// Fetch branding settings
// State to track if logo image has loaded
const [logoLoaded, setLogoLoaded] = useState(false)
// Fetch branding settings with localStorage cache for instant load
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
queryFn: async () => {
const data = await brandingApi.getBranding()
setCachedBranding(data) // Update cache
// Preload logo in background
preloadLogo(data)
return data
},
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
staleTime: 60000, // 1 minute
refetchOnWindowFocus: true,
retry: 1,
})
// Computed branding values - use fallback only if branding not loaded yet
// Computed branding values - use fallback only if no branding and no cache
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO
const hasCustomLogo = branding?.has_custom_logo || false
@@ -210,6 +237,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 },
@@ -258,17 +296,34 @@ export default function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen flex flex-col">
{/* Animated Background */}
<AnimatedBackground />
{/* Header */}
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
<header
className="sticky top-0 z-50 glass border-b border-dark-800/50"
style={{
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
}}
>
<div className="w-full mx-auto px-4 sm:px-6">
<div className="flex justify-between items-center h-16 lg:h-20">
{/* Logo */}
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0">
{hasCustomLogo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-contain" />
) : (
<span className="text-white font-bold text-lg sm:text-xl lg:text-2xl">{logoLetter}</span>
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
{/* Always show letter as fallback */}
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
{logoLetter}
</span>
{/* Logo image with smooth fade-in */}
{hasCustomLogo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && (
@@ -317,6 +372,20 @@ export default function Layout({ children }: LayoutProps) {
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Fullscreen toggle - only show in Telegram WebApp */}
{isTelegramWebApp && isFullscreenSupported && (
<button
onClick={toggleFullscreen}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
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={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
)}
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button
@@ -325,6 +394,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'}`}>
@@ -339,7 +409,10 @@ export default function Layout({ children }: LayoutProps) {
<PromoDiscountBadge />
<TicketNotificationBell isAdmin={isAdminActive()} />
<LanguageSwitcher />
{/* 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">
@@ -358,6 +431,7 @@ export default function Layout({ children }: LayoutProps) {
onClick={logout}
className="btn-icon"
title={t('nav.logout')}
aria-label={t('nav.logout') || 'Logout'}
>
<LogoutIcon />
</button>
@@ -367,6 +441,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>
@@ -389,29 +465,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 */}
@@ -480,6 +560,7 @@ export default function Layout({ children }: LayoutProps) {
<div className="animate-fade-in">
{children}
</div>
</main>
{/* Mobile Bottom Navigation - only core items */}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useMemo, memo } from 'react'
import type { WheelPrize } from '../../api/wheel'
interface FortuneWheelProps {
@@ -8,7 +8,14 @@ interface FortuneWheelProps {
onSpinComplete: () => void
}
export default function FortuneWheel({
// Pre-generate sparkle positions to avoid recalculating on each render
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
top: `${20 + (i * 10) % 60}%`,
left: `${15 + (i * 13) % 70}%`,
delay: `${i * 0.15}s`,
}))
const FortuneWheel = memo(function FortuneWheel({
prizes,
isSpinning,
targetRotation,
@@ -16,17 +23,17 @@ export default function FortuneWheel({
}: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0)
const [lightPattern, setLightPattern] = useState<boolean[]>([])
const [lightPhase, setLightPhase] = useState(0)
// Animated lights effect
// Animated lights effect - use phase instead of random array (less re-renders)
useEffect(() => {
if (isSpinning) {
const interval = setInterval(() => {
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4))
}, 100)
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
}, 250) // Slower interval = better performance
return () => clearInterval(interval)
} else {
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0))
setLightPhase(0)
}
}, [isSpinning])
@@ -42,6 +49,14 @@ export default function FortuneWheel({
}
}, [isSpinning, targetRotation, onSpinComplete])
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
return Array.from({ length: 20 }, (_, i) => {
if (!isSpinning) return i % 2 === 0
return (i + lightPhase) % 3 !== 0
})
}, [isSpinning, lightPhase])
if (prizes.length === 0) {
return (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
@@ -416,19 +431,19 @@ export default function FortuneWheel({
)}
</div>
{/* Sparkle effects when spinning */}
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => (
{SPARKLE_POSITIONS.map((pos, i) => (
<div
key={`sparkle-${i}`}
className="absolute w-2 h-2 bg-yellow-300 rounded-full"
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
style={{
top: `${15 + Math.random() * 70}%`,
left: `${15 + Math.random() * 70}%`,
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`,
animationDelay: `${i * 0.12}s`,
opacity: 0.8,
top: pos.top,
left: pos.left,
animationDelay: pos.delay,
animationDuration: '1.5s',
opacity: 0.7,
}}
/>
))}
@@ -436,4 +451,6 @@ export default function FortuneWheel({
)}
</div>
)
}
})
export default FortuneWheel

View File

@@ -0,0 +1,127 @@
import { useEffect, useState, useCallback } from 'react'
/**
* Hook for Telegram WebApp API integration
* Provides fullscreen mode, safe area insets, and other WebApp features
*/
export function useTelegramWebApp() {
const [isFullscreen, setIsFullscreen] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
useEffect(() => {
if (!webApp) {
setIsTelegramWebApp(false)
return
}
setIsTelegramWebApp(true)
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
// Expand WebApp to full height
webApp.expand()
webApp.ready()
// Listen for fullscreen changes
const handleFullscreenChanged = () => {
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
}
// Listen for safe area changes
const handleSafeAreaChanged = () => {
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
}
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
}
}, [webApp])
const requestFullscreen = useCallback(() => {
if (webApp?.requestFullscreen) {
try {
webApp.requestFullscreen()
} catch (e) {
console.warn('Fullscreen not supported:', e)
}
}
}, [webApp])
const exitFullscreen = useCallback(() => {
if (webApp?.exitFullscreen) {
try {
webApp.exitFullscreen()
} catch (e) {
console.warn('Exit fullscreen failed:', e)
}
}
}, [webApp])
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
exitFullscreen()
} else {
requestFullscreen()
}
}, [isFullscreen, requestFullscreen, exitFullscreen])
const disableVerticalSwipes = useCallback(() => {
if (webApp?.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
}, [webApp])
const enableVerticalSwipes = useCallback(() => {
if (webApp?.enableVerticalSwipes) {
webApp.enableVerticalSwipes()
}
}, [webApp])
// Check if fullscreen is supported (Bot API 8.0+)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
return {
isTelegramWebApp,
isFullscreen,
isFullscreenSupported,
safeAreaInset,
contentSafeAreaInset,
requestFullscreen,
exitFullscreen,
toggleFullscreen,
disableVerticalSwipes,
enableVerticalSwipes,
webApp,
}
}
/**
* Initialize Telegram WebApp on app start
* Call this in main.tsx or App.tsx
*/
export function initTelegramWebApp() {
const webApp = window.Telegram?.WebApp
if (webApp) {
webApp.ready()
webApp.expand()
// Disable vertical swipes to prevent accidental closing
if (webApp.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
}
}

View File

@@ -100,117 +100,90 @@ function generatePalette(baseHex: string): ColorPalette {
return palette as ColorPalette
}
// Generate neutral palette from dark background color (for dark theme)
function generateDarkPalette(darkBgHex: string): ColorPalette {
const { h, s } = hexToHsl(darkBgHex)
// Use very low saturation for neutral colors
const neutralS = Math.min(s, 15)
// Lightness values - from very light (50) to very dark (950)
const lightnessMap: Record<number, number> = {
50: 97,
100: 96,
200: 89,
300: 80,
400: 58,
500: 40,
600: 28,
700: 20,
800: 12,
850: 10,
900: 7,
950: 4,
}
const palette: Partial<ColorPalette> = {}
for (const shade of [...SHADE_LEVELS, 850] as const) {
const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50
const { r, g, b } = hslToRgb(h, neutralS, lightness)
palette[shade as keyof ColorPalette] = rgbToString(r, g, b)
}
return palette as ColorPalette
}
// Generate light theme palette (champagne-like)
function generateLightPalette(lightBgHex: string): ColorPalette {
const { h, s } = hexToHsl(lightBgHex)
// Lightness values for light theme - inverse of dark
const lightnessMap: Record<number, number> = {
50: 100,
100: 98,
200: 91,
300: 83,
400: 74,
500: 64,
600: 55,
700: 42,
800: 31,
900: 21,
950: 10,
}
const palette: Partial<ColorPalette> = {}
for (const shade of SHADE_LEVELS) {
const lightness = lightnessMap[shade]
const { r, g, b } = hslToRgb(h, s, lightness)
palette[shade] = rgbToString(r, g, b)
}
return palette as ColorPalette
// Interpolate between two RGB colors
function interpolateRgb(
rgb1: { r: number; g: number; b: number },
rgb2: { r: number; g: number; b: number },
factor: number
): string {
return rgbToString(
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
)
}
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement
// Generate palettes from base colors
// Generate palettes from status colors
const accentPalette = generatePalette(colors.accent)
const successPalette = generatePalette(colors.success)
const warningPalette = generatePalette(colors.warning)
const errorPalette = generatePalette(colors.error)
// Generate dark/light palettes from background colors
const darkPalette = generateDarkPalette(colors.darkBackground)
const champagnePalette = generateLightPalette(colors.lightBackground)
// === DARK THEME PALETTE ===
// Convert hex colors to RGB
const darkBgRgb = hexToRgb(colors.darkBackground)
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
const darkTextRgb = hexToRgb(colors.darkText)
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
// Apply dark palette
for (const shade of [...SHADE_LEVELS, 850] as const) {
if (darkPalette[shade as keyof ColorPalette]) {
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette])
}
}
// Apply dark palette with actual user colors:
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
// Apply champagne/light palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade])
}
// Transition colors (500-700): interpolate between secondary text and surface
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
// Apply accent palette
// Surface/card colors (800-850): surface color
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
// Background colors (900-950): background color
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
// === LIGHT THEME PALETTE ===
const lightBgRgb = hexToRgb(colors.lightBackground)
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
const lightTextRgb = hexToRgb(colors.lightText)
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
// Apply champagne palette with actual user colors:
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
// Transition colors (500-600): between bg and text
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
// Text colors (700-950): secondary to primary text
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
// === STATUS COLOR PALETTES ===
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
}
// Apply success palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
}
// Apply warning palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
}
// Apply error palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
}
// Apply semantic colors (hex for direct use in some places)
// Apply semantic colors (hex for direct use)
root.style.setProperty('--color-dark-bg', colors.darkBackground)
root.style.setProperty('--color-dark-surface', colors.darkSurface)
root.style.setProperty('--color-dark-text', colors.darkText)

View File

@@ -5,9 +5,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
import { ToastProvider } from './components/Toast'
import { initLogoPreload } from './api/branding'
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
import './i18n'
import './styles/globals.css'
// Initialize Telegram WebApp (expand, disable swipes)
initTelegramWebApp()
// Preload logo from cache immediately on page load
initLogoPreload()
const queryClient = new QueryClient({
defaultOptions: {
queries: {

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

@@ -1,10 +1,17 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin'
import {
statsApi,
type DashboardStats,
type NodeStatus,
type TopReferrersResponse,
type TopCampaignsResponse,
type RecentPaymentsResponse
} 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 +19,39 @@ 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 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>
)
@@ -53,6 +73,36 @@ const RestartIcon = () => (
</svg>
)
const ChevronDownIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
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 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 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 ExclamationIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
)
interface StatCardProps {
title: string
value: string | number
@@ -122,6 +172,8 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
return `${gb.toFixed(1)} GB`
}
const hasError = node.last_status_message && !node.is_connected
return (
<div className={`bg-dark-800/50 backdrop-blur rounded-xl border ${node.is_disabled ? 'border-dark-700' : node.is_connected ? 'border-success-500/30' : 'border-error-500/30'} p-4 hover:border-dark-600 transition-colors`}>
<div className="flex items-start justify-between mb-3">
@@ -137,6 +189,32 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
</span>
</div>
{/* Xray Version & Uptime */}
{(node.xray_version || node.xray_uptime) && (
<div className="flex items-center gap-3 mb-3 text-xs">
{node.xray_version && (
<span className="px-2 py-1 rounded bg-dark-700/50 text-dark-300">
Xray {node.xray_version}
</span>
)}
{node.xray_uptime && (
<span className="text-dark-500">
Uptime: {node.xray_uptime}
</span>
)}
</div>
)}
{/* Error Message */}
{hasError && (
<div className="mb-3 p-2 rounded-lg bg-error-500/10 border border-error-500/20">
<div className="flex items-start gap-2">
<ExclamationIcon />
<span className="text-xs text-error-400 break-all">{node.last_status_message}</span>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="bg-dark-900/50 rounded-lg p-2.5">
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.usersOnline')}</div>
@@ -190,14 +268,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>
@@ -222,6 +300,13 @@ export default function AdminDashboard() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [showAllNodes, setShowAllNodes] = useState(false)
// Extended stats
const [referrers, setReferrers] = useState<TopReferrersResponse | null>(null)
const [campaigns, setCampaigns] = useState<TopCampaignsResponse | null>(null)
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null)
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings')
const fetchStats = async () => {
try {
@@ -237,10 +322,29 @@ export default function AdminDashboard() {
}
}
const fetchExtendedStats = async () => {
try {
const [referrersData, campaignsData, paymentsData] = await Promise.all([
statsApi.getTopReferrers(10),
statsApi.getTopCampaigns(10),
statsApi.getRecentPayments(20),
])
setReferrers(referrersData)
setCampaigns(campaignsData)
setPayments(paymentsData)
} catch (err) {
console.error('Failed to load extended stats:', err)
}
}
useEffect(() => {
fetchStats()
fetchExtendedStats()
// Refresh every 30 seconds
const interval = setInterval(fetchStats, 30000)
const interval = setInterval(() => {
fetchStats()
fetchExtendedStats()
}, 30000)
return () => clearInterval(interval)
}, [])
@@ -319,26 +423,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 +451,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">
@@ -372,17 +478,33 @@ export default function AdminDashboard() {
</div>
{stats?.nodes.nodes && stats.nodes.nodes.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stats.nodes.nodes.map((node) => (
<NodeCard
key={node.uuid}
node={node}
onRestart={handleRestartNode}
onToggle={handleToggleNode}
isLoading={actionLoading === node.uuid}
/>
))}
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(showAllNodes ? stats.nodes.nodes : stats.nodes.nodes.slice(0, 3)).map((node) => (
<NodeCard
key={node.uuid}
node={node}
onRestart={handleRestartNode}
onToggle={handleToggleNode}
isLoading={actionLoading === node.uuid}
/>
))}
</div>
{stats.nodes.nodes.length > 3 && (
<button
onClick={() => setShowAllNodes(!showAllNodes)}
className="mt-4 w-full flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-dark-700/50 text-dark-300 hover:text-dark-100 hover:bg-dark-700 transition-colors"
>
<span className={`transform transition-transform ${showAllNodes ? 'rotate-180' : ''}`}>
<ChevronDownIcon />
</span>
{showAllNodes
? `Скрыть (${stats.nodes.nodes.length - 3})`
: `Показать еще ${stats.nodes.nodes.length - 3} нод`
}
</button>
)}
</>
) : (
<div className="text-center py-8 text-dark-500">
{t('adminDashboard.nodes.noNodes')}
@@ -395,7 +517,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 +541,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>
@@ -474,39 +600,13 @@ export default function AdminDashboard() {
</div>
</div>
{/* Server Stats */}
{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 />
<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">
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.total')}</div>
<div className="text-2xl font-bold text-dark-100">{stats.servers.total_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.available')}</div>
<div className="text-2xl font-bold text-success-400">{stats.servers.available_servers}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.withConnections')}</div>
<div className="text-2xl font-bold text-accent-400">{stats.servers.servers_with_connections}</div>
</div>
<div className="bg-dark-900/50 rounded-lg p-4">
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.revenue')}</div>
<div className="text-2xl font-bold text-warning-400">{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}</div>
</div>
</div>
</div>
)}
{/* Tariff Stats */}
{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>
@@ -553,6 +653,262 @@ export default function AdminDashboard() {
</div>
</div>
)}
{/* Extended Stats Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Referrers */}
{referrers && (referrers.by_earnings.length > 0 || referrers.by_invited.length > 0) && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-2 sm:p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
<UsersIcon />
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Топ рефералов</h2>
<p className="text-xs sm:text-sm text-dark-400">
{referrers.total_referrers} реф., {referrers.total_referrals} пригл.
</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setReferrersTab('earnings')}
className={`px-2 sm:px-3 py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
referrersTab === 'earnings'
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
}`}
>
По доходу
</button>
<button
onClick={() => setReferrersTab('invited')}
className={`px-2 sm:px-3 py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
referrersTab === 'invited'
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
}`}
>
По пригл.
</button>
</div>
<div className="space-y-2">
{(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited).slice(0, 5).map((ref, idx) => (
<div key={ref.user_id} className="flex items-center justify-between p-2 sm:p-3 rounded-lg bg-dark-900/50 hover:bg-dark-800/50 transition-colors gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<span className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0 flex items-center justify-center rounded-full bg-dark-700 text-[10px] sm:text-xs font-bold text-dark-300">
{idx + 1}
</span>
<div className="min-w-0">
<div className="font-medium text-dark-100 text-xs sm:text-sm truncate">{ref.display_name}</div>
{ref.username && <div className="text-[10px] sm:text-xs text-dark-500 truncate">@{ref.username}</div>}
</div>
</div>
<div className="text-right flex-shrink-0">
{referrersTab === 'earnings' ? (
<>
<div className="font-semibold text-success-400 text-xs sm:text-sm">{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}</div>
<div className="text-[10px] sm:text-xs text-dark-500">{ref.invited_count} пригл.</div>
</>
) : (
<>
<div className="font-semibold text-accent-400 text-xs sm:text-sm">{ref.invited_count} чел.</div>
<div className="text-[10px] sm:text-xs text-dark-500">{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}</div>
</>
)}
</div>
</div>
))}
</div>
{/* Period Stats */}
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-4 pt-4 border-t border-dark-700">
<div className="text-center">
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Сегодня</div>
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
{formatAmount(
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
.reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100
)} {currencySymbol}
</div>
</div>
<div className="text-center">
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Неделя</div>
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
{formatAmount(
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
.reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100
)} {currencySymbol}
</div>
</div>
<div className="text-center">
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Месяц</div>
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
{formatAmount(
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
.reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100
)} {currencySymbol}
</div>
</div>
</div>
</div>
)}
{/* Top Campaigns */}
{campaigns && campaigns.campaigns.length > 0 && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
<div className="flex items-center gap-2 sm:gap-3 mb-4">
<div className="p-2 sm:p-2.5 rounded-lg bg-warning-500/20 text-warning-400">
<MegaphoneIcon />
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Топ РК ссылок</h2>
<p className="text-xs sm:text-sm text-dark-400">
{campaigns.total_campaigns} камп., {campaigns.total_registrations} рег.
</p>
</div>
</div>
<div className="space-y-2">
{campaigns.campaigns.slice(0, 5).map((campaign, idx) => (
<div key={campaign.id} className="flex items-center justify-between p-2 sm:p-3 rounded-lg bg-dark-900/50 hover:bg-dark-800/50 transition-colors gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<span className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0 flex items-center justify-center rounded-full bg-dark-700 text-[10px] sm:text-xs font-bold text-dark-300">
{idx + 1}
</span>
<div className="min-w-0">
<div className="font-medium text-dark-100 text-xs sm:text-sm truncate">{campaign.name}</div>
<div className="text-[10px] sm:text-xs text-dark-500 truncate">?start={campaign.start_parameter}</div>
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-semibold text-warning-400 text-xs sm:text-sm">{formatAmount(campaign.total_revenue_kopeks / 100)} {currencySymbol}</div>
<div className="text-[10px] sm:text-xs text-dark-500">
{campaign.registrations} · {campaign.conversion_rate.toFixed(0)}%
</div>
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-dark-700">
<div className="flex justify-between items-center">
<span className="text-xs sm:text-sm text-dark-400">Всего от РК</span>
<span className="font-bold text-warning-400 text-sm sm:text-base">{formatAmount(campaigns.total_revenue_kopeks / 100)} {currencySymbol}</span>
</div>
</div>
</div>
)}
</div>
{/* Recent Payments */}
{payments && payments.payments.length > 0 && (
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 sm:p-2.5 rounded-lg bg-success-500/20 text-success-400">
<BanknotesIcon />
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Последние платежи</h2>
<p className="text-xs sm:text-sm text-dark-400">
Сегодня: {formatAmount(payments.total_today_kopeks / 100)} {currencySymbol}
<span className="hidden sm:inline"> · За неделю: {formatAmount(payments.total_week_kopeks / 100)} {currencySymbol}</span>
</p>
</div>
</div>
</div>
{/* Desktop Table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-700">
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Пользователь</th>
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Тип</th>
<th className="text-right text-xs text-dark-500 font-medium py-3 px-2">Сумма</th>
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Метод</th>
<th className="text-right text-xs text-dark-500 font-medium py-3 px-2">Дата</th>
</tr>
</thead>
<tbody>
{payments.payments.slice(0, 10).map((payment) => (
<tr key={payment.id} className="border-b border-dark-700/50 hover:bg-dark-800/50 transition-colors">
<td className="py-3 px-2">
<div className="font-medium text-dark-100 text-sm">{payment.display_name}</div>
{payment.username && <div className="text-xs text-dark-500">@{payment.username}</div>}
</td>
<td className="py-3 px-2">
<span className={`text-xs px-2 py-1 rounded-full ${
payment.type === 'deposit'
? 'bg-success-500/20 text-success-400'
: 'bg-accent-500/20 text-accent-400'
}`}>
{payment.type_display}
</span>
</td>
<td className="py-3 px-2 text-right">
<span className="font-semibold text-dark-100">{formatAmount(payment.amount_rubles)} {currencySymbol}</span>
</td>
<td className="py-3 px-2">
<span className="text-xs text-dark-400">{payment.payment_method || '-'}</span>
</td>
<td className="py-3 px-2 text-right">
<span className="text-xs text-dark-400">
{new Date(payment.created_at).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden space-y-2">
{payments.payments.slice(0, 10).map((payment) => (
<div key={payment.id} className="p-3 rounded-lg bg-dark-900/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={`text-[10px] px-1.5 py-0.5 rounded-full whitespace-nowrap ${
payment.type === 'deposit'
? 'bg-success-500/20 text-success-400'
: 'bg-accent-500/20 text-accent-400'
}`}>
{payment.type_display}
</span>
<span className="font-medium text-dark-100 text-sm truncate">{payment.display_name}</span>
</div>
<span className="font-semibold text-dark-100 text-sm whitespace-nowrap ml-2">
{formatAmount(payment.amount_rubles)} {currencySymbol}
</span>
</div>
<div className="flex items-center justify-between text-xs text-dark-500">
<span>{payment.payment_method || '-'}</span>
<span>
{new Date(payment.created_at).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

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

@@ -87,6 +87,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
subscription_days: 'Дни подписки',
trial_subscription: 'Тестовая подписка',
promo_group: 'Группа скидок',
discount: 'Скидка %',
}
return labels[type] || type
}
@@ -97,6 +98,7 @@ const getTypeColor = (type: PromoCodeType): string => {
subscription_days: 'bg-blue-500/20 text-blue-400',
trial_subscription: 'bg-purple-500/20 text-purple-400',
promo_group: 'bg-amber-500/20 text-amber-400',
discount: 'bg-pink-500/20 text-pink-400',
}
return colors[type] || 'bg-dark-600 text-dark-300'
}
@@ -146,10 +148,14 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
const [promoGroupId, setPromoGroupId] = useState<number | null>(promocode?.promo_group_id || null)
const handleSubmit = () => {
// Для discount: balance_bonus_kopeks = процент (целое число), subscription_days = часы
// Для balance: balance_bonus_kopeks = рубли * 100
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
code: code.trim().toUpperCase(),
type,
balance_bonus_kopeks: Math.round(balanceBonusRubles * 100),
balance_bonus_kopeks: type === 'discount'
? Math.round(balanceBonusRubles) // процент как целое число
: Math.round(balanceBonusRubles * 100), // рубли в копейки
subscription_days: subscriptionDays,
max_uses: maxUses,
is_active: isActive,
@@ -165,6 +171,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
if (type === 'balance' && balanceBonusRubles <= 0) return false
if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) return false
if (type === 'promo_group' && !promoGroupId) return false
if (type === 'discount' && (balanceBonusRubles <= 0 || balanceBonusRubles > 100 || subscriptionDays <= 0)) return false
return true
}
@@ -207,6 +214,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
<option value="subscription_days">Дни подписки</option>
<option value="trial_subscription">Тестовая подписка</option>
<option value="promo_group">Группа скидок</option>
<option value="discount">Процентная скидка</option>
</select>
</div>
@@ -262,6 +270,40 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
</div>
)}
{type === 'discount' && (
<>
<div>
<label className="block text-sm text-dark-300 mb-1">Процент скидки</label>
<div className="flex items-center gap-2">
<input
type="number"
value={balanceBonusRubles}
onChange={e => setBalanceBonusRubles(Math.min(100, Math.max(0, parseFloat(e.target.value) || 0)))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
max={100}
/>
<span className="text-dark-400">%</span>
</div>
<p className="text-xs text-dark-500 mt-1">Скидка применяется один раз при оплате</p>
</div>
<div>
<label className="block text-sm text-dark-300 mb-1">Время действия</label>
<div className="flex items-center gap-2">
<input
type="number"
value={subscriptionDays}
onChange={e => setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
<span className="text-dark-400">часов</span>
</div>
<p className="text-xs text-dark-500 mt-1">Сколько часов после активации скидка действует</p>
</div>
</>
)}
{/* Max Uses */}
<div>
<label className="block text-sm text-dark-300 mb-1">Макс. использований</label>
@@ -578,6 +620,18 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
<span className="text-blue-400">+{promocode.subscription_days}</span>
</div>
)}
{promocode.type === 'discount' && (
<>
<div className="flex justify-between">
<span className="text-dark-400">Скидка:</span>
<span className="text-pink-400">-{promocode.balance_bonus_kopeks}%</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Действует:</span>
<span className="text-pink-400">{promocode.subscription_days} часов</span>
</div>
</>
)}
<div className="flex justify-between">
<span className="text-dark-400">Лимит:</span>
<span className="text-dark-200">
@@ -936,6 +990,9 @@ export default function AdminPromocodes() {
{(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && (
<span className="text-blue-400">+{promo.subscription_days} дней</span>
)}
{promo.type === 'discount' && (
<span className="text-pink-400">-{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч</span>
)}
<span>
Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses}
</span>

View File

@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings'
import { brandingApi } from '../api/branding'
import { brandingApi, setCachedBranding } from '../api/branding'
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
import { themeColorsApi } from '../api/themeColors'
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
import { ColorPicker } from '../components/ColorPicker'
@@ -864,9 +865,25 @@ export default function AdminSettings() {
queryFn: brandingApi.getBranding,
})
// Animation toggle query and mutation
const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled,
})
const updateAnimationMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
onSuccess: (data) => {
// Update local cache immediately for instant effect
setCachedAnimationEnabled(data.enabled)
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
},
})
const updateNameMutation = useMutation({
mutationFn: (name: string) => brandingApi.updateName(name),
onSuccess: () => {
onSuccess: (data) => {
setCachedBranding(data) // Update cache immediately
queryClient.invalidateQueries({ queryKey: ['branding'] })
setEditingName(false)
},
@@ -874,14 +891,16 @@ export default function AdminSettings() {
const uploadLogoMutation = useMutation({
mutationFn: (file: File) => brandingApi.uploadLogo(file),
onSuccess: () => {
onSuccess: (data) => {
setCachedBranding(data) // Update cache immediately
queryClient.invalidateQueries({ queryKey: ['branding'] })
},
})
const deleteLogoMutation = useMutation({
mutationFn: () => brandingApi.deleteLogo(),
onSuccess: () => {
onSuccess: (data) => {
setCachedBranding(data) // Update cache immediately
queryClient.invalidateQueries({ queryKey: ['branding'] })
},
})
@@ -1187,6 +1206,29 @@ export default function AdminSettings() {
</p>
</div>
</div>
{/* Animation Toggle */}
<div className="mt-6 pt-6 border-t border-dark-700/50">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-dark-200">Анимированный фон</h3>
<p className="text-xs text-dark-500 mt-0.5">
Волновая анимация на фоне для всех пользователей
</p>
</div>
<button
onClick={() => updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true))}
disabled={updateAnimationMutation.isPending}
className={`relative w-12 h-6 rounded-full transition-colors ${
(animationSettings?.enabled ?? true) ? 'bg-accent-500' : 'bg-dark-600'
} ${updateAnimationMutation.isPending ? 'opacity-50' : ''}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
(animationSettings?.enabled ?? true) ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
</div>
{/* Theme Colors Card */}

View File

@@ -9,8 +9,7 @@ import {
TariffCreateRequest,
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ServerTrafficLimit
ServerInfo
} from '../api/tariffs'
// Icons
@@ -146,9 +145,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
tariff?.period_prices?.length ? tariff.period_prices : []
)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
tariff?.server_traffic_limits || {}
)
// Докупка трафика
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
@@ -159,6 +155,18 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
// Режим сброса трафика
const [trafficResetMode, setTrafficResetMode] = useState<string | null>(tariff?.traffic_reset_mode || null)
// Плавающий тариф - произвольное количество дней
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
// Плавающий тариф - произвольный трафик
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
// Новый период для добавления
const [newPeriodDays, setNewPeriodDays] = useState(30)
const [newPeriodPrice, setNewPeriodPrice] = useState(300)
@@ -166,13 +174,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic')
const handleSubmit = () => {
const filteredLimits: Record<string, ServerTrafficLimit> = {}
for (const uuid of selectedSquads) {
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
filteredLimits[uuid] = serverTrafficLimits[uuid]
}
}
const data: TariffCreateRequest | TariffUpdateRequest = {
name,
description: description || undefined,
@@ -183,24 +184,25 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
tier_level: tierLevel,
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb,
is_daily: false,
daily_price_kopeks: 0,
traffic_reset_mode: trafficResetMode,
// Плавающий тариф
custom_days_enabled: customDaysEnabled,
price_per_day_kopeks: pricePerDayKopeks,
min_days: minDays,
max_days: maxDays,
custom_traffic_enabled: customTrafficEnabled,
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
min_traffic_gb: minTrafficGb,
max_traffic_gb: maxTrafficGb,
}
onSave(data)
}
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
@@ -430,27 +432,24 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
{activeTab === 'servers' && (
<div className="space-y-2">
<p className="text-sm text-dark-400 mb-4">
Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого.
Выберите серверы, доступные на этом тарифе.
</p>
{servers.length === 0 ? (
<p className="text-dark-500 text-center py-4">Нет доступных серверов</p>
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return (
<div
key={server.id}
className={`p-3 rounded-lg transition-colors ${
onClick={() => toggleServer(server.squad_uuid)}
className={`p-3 rounded-lg transition-colors cursor-pointer ${
isSelected
? 'bg-accent-500/20 border border-accent-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`}
>
<div
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected
? 'bg-accent-500 text-white'
@@ -463,27 +462,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
<span className="text-xs text-dark-500">{server.country_code}</span>
)}
</div>
{isSelected && (
<div className="mt-2 ml-8 flex items-center gap-2">
<span className="text-xs text-dark-400">Лимит трафика:</span>
<input
type="number"
value={serverLimit}
onClick={e => e.stopPropagation()}
onChange={e => {
e.stopPropagation()
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder="0"
/>
<span className="text-xs text-dark-400">ГБ</span>
{serverLimit === 0 && (
<span className="text-xs text-dark-500">(использовать общий)</span>
)}
</div>
)}
</div>
)
})
@@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
)}
</div>
{/* Плавающий тариф - произвольное количество дней */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-dark-200">Произвольное количество дней</h4>
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает срок подписки</p>
</div>
<button
type="button"
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customDaysEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customDaysEnabled && (
<div className="space-y-3 pt-2 border-t border-dark-600">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={pricePerDayKopeks / 100}
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
<input
type="number"
value={minDays}
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
<input
type="number"
value={maxDays}
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Плавающий тариф - произвольный трафик */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-dark-200">Произвольный объём трафика</h4>
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает объём трафика при покупке</p>
</div>
<button
type="button"
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customTrafficEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customTrafficEnabled && (
<div className="space-y-3 pt-2 border-t border-dark-600">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
<input
type="number"
value={trafficPricePerGbKopeks / 100}
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
<input
type="number"
value={minTrafficGb}
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
<input
type="number"
value={maxTrafficGb}
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Режим сброса трафика */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-200 mb-3">Режим сброса трафика</h4>
@@ -636,7 +732,7 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
</button>
<button
onClick={handleSubmit}
disabled={!name || periodPrices.length === 0 || isLoading}
disabled={!name || (periodPrices.length === 0 && !customDaysEnabled) || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
@@ -668,9 +764,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
tariff?.server_traffic_limits || {}
)
// Докупка трафика
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
@@ -684,13 +777,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
const [activeTab, setActiveTab] = useState<'basic' | 'servers' | 'extra'>('basic')
const handleSubmit = () => {
const filteredLimits: Record<string, ServerTrafficLimit> = {}
for (const uuid of selectedSquads) {
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
filteredLimits[uuid] = serverTrafficLimits[uuid]
}
}
const data: TariffCreateRequest | TariffUpdateRequest = {
name,
description: description || undefined,
@@ -701,7 +787,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
tier_level: tierLevel,
period_prices: [],
allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb,
@@ -712,13 +797,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
onSave(data)
}
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
@@ -870,20 +948,17 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return (
<div
key={server.id}
className={`p-3 rounded-lg transition-colors ${
onClick={() => toggleServer(server.squad_uuid)}
className={`p-3 rounded-lg transition-colors cursor-pointer ${
isSelected
? 'bg-amber-500/20 border border-amber-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`}
>
<div
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected
? 'bg-amber-500 text-white'
@@ -896,26 +971,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
<span className="text-xs text-dark-500">{server.country_code}</span>
)}
</div>
{isSelected && (
<div className="mt-2 ml-8 flex items-center gap-2">
<span className="text-xs text-dark-400">Лимит трафика:</span>
<input
type="number"
value={serverLimit}
onClick={e => e.stopPropagation()}
onChange={e => {
e.stopPropagation()
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-amber-500"
min={0}
/>
<span className="text-xs text-dark-400">ГБ</span>
{serverLimit === 0 && (
<span className="text-xs text-dark-500">(использовать общий)</span>
)}
</div>
)}
</div>
)
})

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

@@ -32,15 +32,33 @@ const ChevronRightIcon = () => (
</svg>
)
const SupportLottieIcon = () => (
<div className="w-6 h-6">
<DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
loop
autoplay
/>
</div>
)
// Check if device might be low-performance (Telegram WebApp on mobile)
const isLowPerfDevice = (() => {
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
return isTelegramWebApp && isMobile
})()
const SupportLottieIcon = () => {
// Use static icon on low-performance devices
if (isLowPerfDevice) {
return (
<svg className="w-6 h-6" 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>
)
}
return (
<div className="w-6 h-6">
<DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
loop
autoplay
/>
</div>
)
}
export default function Dashboard() {
const { t } = useTranslation()

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

@@ -3,47 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth'
import { brandingApi, type BrandingInfo } from '../api/branding'
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, type BrandingInfo } from '../api/branding'
import { getAndClearReturnUrl } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton'
const BRANDING_CACHE_KEY = 'cabinet-branding-cache'
const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour
const getCachedBranding = (): BrandingInfo | undefined => {
if (typeof window === 'undefined') {
return undefined
}
try {
const raw = localStorage.getItem(BRANDING_CACHE_KEY)
if (!raw) return undefined
const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number }
if (!parsed?.data || !parsed.timestamp) return undefined
if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) {
localStorage.removeItem(BRANDING_CACHE_KEY)
return undefined
}
return parsed.data
} catch {
return undefined
}
}
const cacheBranding = (data: BrandingInfo) => {
if (typeof window === 'undefined') {
return
}
try {
localStorage.setItem(
BRANDING_CACHE_KEY,
JSON.stringify({ data, timestamp: Date.now() })
)
} catch {
// Ignore storage errors (e.g., private mode)
}
}
export default function Login() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -55,6 +19,7 @@ export default function Login() {
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [logoLoaded, setLogoLoaded] = useState(false)
// Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => {
@@ -72,22 +37,21 @@ export default function Login() {
return '/'
}, [location.state])
// Fetch branding
// Fetch branding with unified cache
const cachedBranding = useMemo(() => getCachedBranding(), [])
const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
queryFn: async () => {
const data = await brandingApi.getBranding()
setCachedBranding(data)
preloadLogo(data)
return data
},
staleTime: 60000,
placeholderData: cachedBranding,
initialData: cachedBranding ?? undefined,
})
useEffect(() => {
if (branding) {
cacheBranding(branding)
}
}, [branding])
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
@@ -117,7 +81,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 +103,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)
}
@@ -158,11 +132,19 @@ export default function Login() {
<div className="relative max-w-md w-full space-y-8">
{/* Logo */}
<div className="text-center">
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
{branding?.has_custom_logo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold text-2xl">{appLogo}</span>
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden relative">
{/* Always show letter as fallback */}
<span className={`text-white font-bold text-2xl absolute transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
{appLogo}
</span>
{/* Logo image with smooth fade-in */}
{branding?.has_custom_logo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={`w-full h-full object-cover absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && (

View File

@@ -318,7 +318,7 @@ export default function Support() {
if (webApp?.openLink) {
log.debug('Using openLink')
try {
webApp.openLink(webUrl)
webApp.openLink(webUrl, { try_browser: true })
return
} catch (e) {
log.error('openLink failed:', e)
@@ -340,7 +340,7 @@ export default function Support() {
buttonAction: () => {
const webApp = window.Telegram?.WebApp
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!)
webApp.openLink(supportConfig.support_url!, { try_browser: true })
} else {
window.open(supportConfig.support_url!, '_blank')
}
@@ -372,7 +372,7 @@ export default function Support() {
webApp.openTelegramLink(webUrl)
} else if (webApp?.openLink) {
log.debug('Fallback using openLink')
webApp.openLink(webUrl)
webApp.openLink(webUrl, { try_browser: true })
} else {
log.debug('Fallback using window.open')
window.open(webUrl, '_blank')

View File

@@ -6,6 +6,15 @@ import FortuneWheel from '../components/wheel/FortuneWheel'
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
import { useCurrency } from '../hooks/useCurrency'
// Pre-calculated confetti positions (stable across re-renders)
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${(i * 17 + 5) % 95}%`,
top: `${(i * 23 + 3) % 90}%`,
delay: `${(i * 0.1) % 2}s`,
duration: `${1 + (i % 3) * 0.3}s`,
}))
// Icons
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -98,10 +107,12 @@ export default function Wheel() {
}, [config, isTelegramMiniApp])
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
const pollForSpinResult = useCallback(async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
// Wait a bit before first poll to give the bot time to process the payment
await new Promise(resolve => setTimeout(resolve, 1500))
if (signal.aborted) return null
// Get current history to find the latest spin ID
let historyBefore
try {
@@ -112,8 +123,12 @@ export default function Wheel() {
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal.aborted) return null
await new Promise(resolve => setTimeout(resolve, delayMs))
if (signal.aborted) return null
try {
const historyAfter = await wheelApi.getHistory(1, 1)
@@ -152,6 +167,16 @@ export default function Wheel() {
// Ref to store pending Stars payment result
const pendingStarsResultRef = useRef<SpinResult | null>(null)
const isStarsSpinRef = useRef(false)
const pollingAbortRef = useRef<AbortController | null>(null)
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
}
}
}, [])
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
@@ -163,6 +188,12 @@ export default function Wheel() {
isStarsSpinRef.current = true
pendingStarsResultRef.current = null
// Cancel any existing polling
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
}
pollingAbortRef.current = new AbortController()
// Payment done - reset paying state immediately
setIsPayingStars(false)
@@ -172,7 +203,10 @@ export default function Wheel() {
// Poll for the result in the background - don't await here!
// The result will be stored and shown when animation completes
pollForSpinResult().then((result) => {
const abortSignal = pollingAbortRef.current.signal
pollForSpinResult(abortSignal).then((result) => {
if (abortSignal.aborted) return
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
@@ -195,6 +229,8 @@ export default function Wheel() {
}
}
}).catch(() => {
if (abortSignal.aborted) return
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
@@ -551,7 +587,7 @@ export default function Wheel() {
}`}
style={{
backgroundSize: '200% 100%',
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
}}
>
{/* Button glow effect */}
@@ -669,18 +705,18 @@ export default function Wheel() {
<>
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
{/* Confetti effect */}
{/* Confetti effect - using pre-calculated positions */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
{CONFETTI_POSITIONS.map((pos, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-bounce"
style={{
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${1 + Math.random()}s`,
background: pos.color,
left: pos.left,
top: pos.top,
animationDelay: pos.delay,
animationDuration: pos.duration,
}}
/>
))}
@@ -747,12 +783,6 @@ export default function Wheel() {
</div>
)}
<style>{`
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
`}</style>
</div>
)
}

View File

@@ -102,19 +102,34 @@
html {
overflow-x: hidden;
scroll-behavior: smooth;
}
/* Smooth scroll only on desktop - mobile uses native */
@media (min-width: 1024px) {
html {
scroll-behavior: smooth;
}
}
/* Dark theme (default) */
body, .dark body {
@apply bg-dark-950 text-dark-100 font-sans antialiased;
overscroll-behavior-y: contain;
/* iOS smooth scrolling */
-webkit-overflow-scrolling: touch;
}
/* Optimize main scrollable content */
main {
/* Prevent layout shifts during scroll */
contain: layout style;
}
/* Light theme - Champagne */
.light body {
@apply bg-champagne-200 text-champagne-900 font-sans antialiased;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
/* Light theme text color overrides - convert dark-* text colors to readable colors */
@@ -310,10 +325,19 @@
@layer components {
/* ========== DARK THEME COMPONENTS (default) ========== */
/* Cards - Dark */
/* Cards - Dark (optimized for mobile) */
.card {
@apply bg-dark-900/50 backdrop-blur-sm rounded-2xl border border-dark-800/50 p-5 sm:p-6
@apply bg-dark-900/70 rounded-2xl border border-dark-800/50 p-5 sm:p-6
transition-all duration-300 ease-smooth;
/* GPU acceleration for smooth scroll */
transform: translateZ(0);
}
/* Enable backdrop-blur only on desktop */
@media (min-width: 1024px) {
.card {
@apply bg-dark-900/50 backdrop-blur-sm;
}
}
.card-hover {
@@ -329,9 +353,19 @@
@apply card animate-slide-up;
}
/* Glass effect - Dark */
/* Glass effect - Dark (optimized for mobile) */
.glass {
@apply bg-dark-900/30 backdrop-blur-xl border border-dark-700/30;
@apply bg-dark-900/80 border border-dark-700/30;
/* GPU acceleration */
transform: translateZ(0);
will-change: transform;
}
/* Enable backdrop-blur only on desktop where it's performant */
@media (min-width: 1024px) {
.glass {
@apply bg-dark-900/30 backdrop-blur-xl;
}
}
/* Buttons - Dark */
@@ -455,11 +489,18 @@
@apply nav-item text-accent-400 bg-accent-500/10;
}
/* Bottom nav - Dark */
/* Bottom nav - Dark (optimized for mobile) */
.bottom-nav {
@apply fixed bottom-0 left-0 right-0 z-50
bg-dark-900/80 backdrop-blur-xl border-t border-dark-800/50
bg-dark-900/95 border-t border-dark-800/50
safe-area-pb;
transform: translateZ(0);
}
@media (min-width: 1024px) {
.bottom-nav {
@apply bg-dark-900/80 backdrop-blur-xl;
}
}
.bottom-nav-item {
@@ -476,12 +517,34 @@
@apply border-t border-dark-800/50;
}
/* ========== MOBILE PERFORMANCE: Disable backdrop-blur ========== */
@media (max-width: 1023px) {
/* Disable all backdrop-blur on mobile for better scroll performance */
.backdrop-blur,
.backdrop-blur-sm,
.backdrop-blur-md,
.backdrop-blur-lg,
.backdrop-blur-xl,
.backdrop-blur-2xl,
.backdrop-blur-3xl {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* ========== LIGHT THEME COMPONENTS (Champagne) ========== */
/* Cards - Light */
.light .card {
@apply bg-white/80 backdrop-blur-sm rounded-2xl border border-champagne-300/50 p-5 sm:p-6
@apply bg-white/90 rounded-2xl border border-champagne-300/50 p-5 sm:p-6
shadow-sm;
transform: translateZ(0);
}
@media (min-width: 1024px) {
.light .card {
@apply bg-white/80 backdrop-blur-sm;
}
}
.light .card-hover {
@@ -492,9 +555,16 @@
@apply hover:shadow-lg hover:border-accent-400/40;
}
/* Glass effect - Light */
/* Glass effect - Light (optimized for mobile) */
.light .glass {
@apply bg-white/60 backdrop-blur-xl border border-champagne-300/40;
@apply bg-white/90 border border-champagne-300/40;
transform: translateZ(0);
}
@media (min-width: 1024px) {
.light .glass {
@apply bg-white/60 backdrop-blur-xl;
}
}
/* Buttons - Light */
@@ -884,6 +954,37 @@
}
}
/* Color picker range input styling */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Selection color */
::selection {
@apply bg-accent-500/30 text-dark-50;
@@ -1003,6 +1104,11 @@
}
/* Fortune Wheel animations */
@keyframes wheel-shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
@keyframes wheel-glow {
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
@@ -1050,3 +1156,99 @@
from { width: 100%; }
to { width: 0%; }
}
/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */
.wave-bg-container {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
contain: strict; /* Performance: isolate repaints */
}
.wave-blob {
position: absolute;
border-radius: 50%;
filter: blur(60px); /* Reduced from 80px for better performance */
opacity: 0.5;
/* GPU acceleration */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
/* Smaller sizes for better performance */
.wave-blob-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%);
top: -15%;
left: -10%;
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
}
.wave-blob-2 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%);
bottom: -10%;
right: -10%;
animation: wave2 25s ease-in-out infinite;
}
.wave-blob-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%);
top: 40%;
left: 40%;
animation: wave3 30s ease-in-out infinite;
}
.wave-blob-4 {
width: 250px;
height: 250px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%);
bottom: 25%;
left: 15%;
animation: wave4 22s ease-in-out infinite;
}
/* Simplified keyframes - fewer steps = better performance */
@keyframes wave1 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(8%, 12%, 0) scale(1.05); }
}
@keyframes wave2 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(-10%, -8%, 0) scale(1.08); }
}
@keyframes wave3 {
0%, 100% { transform: translate3d(-50%, -50%, 0) scale(1); }
50% { transform: translate3d(-45%, -55%, 0) scale(1.1); }
}
@keyframes wave4 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(12%, -8%, 0) scale(1.12); }
}
/* Light theme adjustments */
.light .wave-blob {
opacity: 0.6;
filter: blur(70px);
}
.light .wave-blob-1 {
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
}
/* Disable animations for reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.wave-blob {
animation: none !important;
}
}

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
}

25
src/vite-env.d.ts vendored
View File

@@ -25,12 +25,35 @@ interface TelegramWebApp {
auth_date: number
hash: string
}
version: string
platform: string
isExpanded: boolean
isClosingConfirmationEnabled: boolean
isVerticalSwipesEnabled: boolean
isFullscreen: boolean
isOrientationLocked: boolean
safeAreaInset: { top: number; bottom: number; left: number; right: number }
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }
ready: () => void
expand: () => void
close: () => void
openLink: (url: string, options?: { try_instant_view?: boolean }) => void
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
openTelegramLink: (url: string) => void
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
// Fullscreen API (Bot API 8.0+)
requestFullscreen: () => void
exitFullscreen: () => void
lockOrientation: () => void
unlockOrientation: () => void
// Vertical swipes control
disableVerticalSwipes: () => void
enableVerticalSwipes: () => void
// Closing confirmation
enableClosingConfirmation: () => void
disableClosingConfirmation: () => void
// Event handlers
onEvent: (eventType: string, callback: () => void) => void
offEvent: (eventType: string, callback: () => void) => void
MainButton: {
text: string
color: string