diff --git a/Dockerfile b/Dockerfile index 8293e10..de296d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /app COPY package.json package-lock.json* ./ # Install dependencies -# Use npm install if no lock file, npm ci if lock file exists RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi # Copy source code diff --git a/src/api/admin.ts b/src/api/admin.ts index cc935f6..1bd79fe 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -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 => { + const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } }) + return response.data + }, + + // Get top campaigns + getTopCampaigns: async (limit: number = 20): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } }) + return response.data + }, + + // Get recent payments + getRecentPayments: async (limit: number = 50): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } }) + return response.data + }, } diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 51a69af..b57a78c 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -9,6 +9,7 @@ export interface LocalizedText { } export interface AppButton { + id?: string // Unique identifier for React key (client-side only) buttonLink: string buttonText: LocalizedText } diff --git a/src/api/branding.ts b/src/api/branding.ts index e948691..8d21793 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -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 => { + 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 => { @@ -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 => { + const response = await apiClient.get('/cabinet/branding/animation') + return response.data + }, + + // Update animation enabled (admin only) + updateAnimationEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/animation', { enabled }) + return response.data + }, } diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index f2589d9..b1ecb2c 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -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 diff --git a/src/api/wheel.ts b/src/api/wheel.ts index c076e3b..7511f94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -98,6 +98,22 @@ export interface WheelPrizeAdmin { updated_at: string | null } +// Type for creating a new prize (excludes id, config_id which are auto-generated) +export interface CreateWheelPrizeData { + prize_type: string + prize_value: number + display_name: string + emoji?: string + color?: string + prize_value_kopeks: number + sort_order?: number + manual_probability?: number | null + is_active?: boolean + promo_balance_bonus_kopeks?: number + promo_subscription_days?: number + promo_traffic_gb?: number +} + export interface AdminWheelConfig { id: number is_enabled: boolean @@ -223,20 +239,7 @@ export const adminWheelApi = { }, // Create prize - createPrize: async (data: { - prize_type: string - prize_value: number - display_name: string - emoji?: string - color?: string - prize_value_kopeks: number - sort_order?: number - manual_probability?: number | null - is_active?: boolean - promo_balance_bonus_kopeks?: number - promo_subscription_days?: number - promo_traffic_gb?: number - }): Promise => { + createPrize: async (data: CreateWheelPrizeData): Promise => { const response = await apiClient.post('/cabinet/admin/wheel/prizes', data) return response.data }, diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx new file mode 100644 index 0000000..fe54b1b --- /dev/null +++ b/src/components/AnimatedBackground.tsx @@ -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(() => 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 ( +
+
+
+ {!isMobile && ( + <> +
+
+ + )} +
+ ) +}) + +export default AnimatedBackground diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 9889dd0..6b90673 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -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(null) const colorInputRef = useRef(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 &&

{description}

}
- {/* Color preview button */} + {/* Color preview button - min 44px for touch accessibility */} + + {/* Native picker button - min 44px for touch accessibility */} + + + )}
- {/* Dropdown with presets */} + {/* Dropdown with presets and RGB sliders */} {isOpen && ( -
+
+ {/* RGB Sliders - shown in Telegram instead of native picker */} + {isTelegram && ( +
+
RGB
+
+ {/* Red */} +
+ R + 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}))`, + }} + /> + {rgb.r} +
+ {/* Green */} +
+ G + 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}))`, + }} + /> + {rgb.g} +
+ {/* Blue */} +
+ B + 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))`, + }} + /> + {rgb.b} +
+
+
+ )} +
Preset colors
-
+
{PRESET_COLORS.map((preset) => (
diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2ac3e2b..473022d 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -61,12 +61,8 @@ const platformIconComponents: Record = { // 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) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index 702a4fa..f921a43 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -152,7 +152,7 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod {/* Header */}
{t('balance.selectPaymentMethod')} - {/* Dropdown - mobile: fixed centered, desktop: absolute right */} diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index b2cd7da..57f9f61 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -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(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 && ( -
+
{/* Header */}

diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 6cf974b..056c19c 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -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) } diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index ad725d1..0c82aee 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -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 = () => ( ) +const FullscreenIcon = () => ( + + + +) + +const ExitFullscreenIcon = () => ( + + + +) + 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(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 (
+ {/* Animated Background */} + + {/* Header */} -
+
{/* Logo */} -
- {hasCustomLogo && logoUrl ? ( - {appName - ) : ( - {logoLetter} +
+ {/* Always show letter as fallback */} + + {logoLetter} + + {/* Logo image with smooth fade-in */} + {hasCustomLogo && logoUrl && ( + {appName setLogoLoaded(true)} + /> )}
{appName && ( @@ -317,6 +372,20 @@ export default function Layout({ children }: LayoutProps) { {/* Right side */}
+ {/* Fullscreen toggle - only show in Telegram WebApp */} + {isTelegramWebApp && isFullscreenSupported && ( + + )} + {/* Theme toggle button - only show if both themes are enabled */} {canToggle && ( @@ -367,6 +441,8 @@ export default function Layout({ children }: LayoutProps) { @@ -389,29 +465,33 @@ export default function Layout({ children }: LayoutProps) {
{/* User info */} -
- {userPhotoUrl ? ( - Avatar { - e.currentTarget.style.display = 'none' - e.currentTarget.nextElementSibling?.classList.remove('hidden') - }} - /> - ) : null} -
- -
-
-
- {user?.first_name || user?.username} +
+
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} +
+
-
- @{user?.username || `ID: ${user?.telegram_id}`} +
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+ {/* Language switcher in mobile menu when promo is active */} + {isPromoActive && }
{/* Nav items */} @@ -480,6 +560,7 @@ export default function Layout({ children }: LayoutProps) {
{children}
+ {/* Mobile Bottom Navigation - only core items */} diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index ed9b2fd..e25e232 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -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(null) const [currentRotation, setCurrentRotation] = useState(0) - const [lightPattern, setLightPattern] = useState([]) + 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 (
@@ -416,19 +431,19 @@ export default function FortuneWheel({ )}
- {/* Sparkle effects when spinning */} + {/* Sparkle effects when spinning - optimized with pre-calculated positions */} {isSpinning && (
- {Array.from({ length: 12 }).map((_, i) => ( + {SPARKLE_POSITIONS.map((pos, i) => (
))} @@ -436,4 +451,6 @@ export default function FortuneWheel({ )}
) -} +}) + +export default FortuneWheel diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts new file mode 100644 index 0000000..e8402b4 --- /dev/null +++ b/src/hooks/useTelegramWebApp.ts @@ -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() + } + } +} diff --git a/src/hooks/useThemeColors.ts b/src/hooks/useThemeColors.ts index f0fa4eb..4fc5837 100644 --- a/src/hooks/useThemeColors.ts +++ b/src/hooks/useThemeColors.ts @@ -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 = { - 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 = {} - - 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 = { - 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 = {} - - 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) diff --git a/src/main.tsx b/src/main.tsx index 6297c78..8a759f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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: { diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 0a25598..b702355 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => { const step = editedApp[stepKey] || emptyAppStep() const buttons = step.buttons || [] - updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }]) + // Generate unique id for React key + const newButton: AppButton = { + id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + buttonLink: '', + buttonText: emptyLocalizedText() + } + updateButtons(stepKey, [...buttons, newButton]) } const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => { @@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
{buttons.map((button, index) => ( -
+
{t('admin.apps.button')} #{index + 1} + )} + ) : (
{t('adminDashboard.nodes.noNodes')} @@ -395,7 +517,9 @@ export default function AdminDashboard() { {/* Revenue Chart */}
- +
+ +

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

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

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

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

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

@@ -474,39 +600,13 @@ export default function AdminDashboard() {
- {/* Server Stats */} - {stats?.servers && ( -
-
- -

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

-
-
-
-
{t('adminDashboard.servers.total')}
-
{stats.servers.total_servers}
-
-
-
{t('adminDashboard.servers.available')}
-
{stats.servers.available_servers}
-
-
-
{t('adminDashboard.servers.withConnections')}
-
{stats.servers.servers_with_connections}
-
-
-
{t('adminDashboard.servers.revenue')}
-
{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}
-
-
-
- )} - {/* Tariff Stats */} {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
- +
+ +

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

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

@@ -553,6 +653,262 @@ export default function AdminDashboard() {
)} + + {/* Extended Stats Grid */} +
+ {/* Top Referrers */} + {referrers && (referrers.by_earnings.length > 0 || referrers.by_invited.length > 0) && ( +
+
+
+
+ +
+
+

Топ рефералов

+

+ {referrers.total_referrers} реф., {referrers.total_referrals} пригл. +

+
+
+
+ + {/* Tabs */} +
+ + +
+ +
+ {(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited).slice(0, 5).map((ref, idx) => ( +
+
+ + {idx + 1} + +
+
{ref.display_name}
+ {ref.username &&
@{ref.username}
} +
+
+
+ {referrersTab === 'earnings' ? ( + <> +
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
+
{ref.invited_count} пригл.
+ + ) : ( + <> +
{ref.invited_count} чел.
+
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
+ + )} +
+
+ ))} +
+ + {/* Period Stats */} +
+
+
Сегодня
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
Неделя
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
Месяц
+
+ {formatAmount( + (referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited) + .reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100 + )} {currencySymbol} +
+
+
+
+ )} + + {/* Top Campaigns */} + {campaigns && campaigns.campaigns.length > 0 && ( +
+
+
+ +
+
+

Топ РК ссылок

+

+ {campaigns.total_campaigns} камп., {campaigns.total_registrations} рег. +

+
+
+ +
+ {campaigns.campaigns.slice(0, 5).map((campaign, idx) => ( +
+
+ + {idx + 1} + +
+
{campaign.name}
+
?start={campaign.start_parameter}
+
+
+
+
{formatAmount(campaign.total_revenue_kopeks / 100)} {currencySymbol}
+
+ {campaign.registrations} · {campaign.conversion_rate.toFixed(0)}% +
+
+
+ ))} +
+ +
+
+ Всего от РК + {formatAmount(campaigns.total_revenue_kopeks / 100)} {currencySymbol} +
+
+
+ )} +
+ + {/* Recent Payments */} + {payments && payments.payments.length > 0 && ( +
+
+
+
+ +
+
+

Последние платежи

+

+ Сегодня: {formatAmount(payments.total_today_kopeks / 100)} {currencySymbol} + · За неделю: {formatAmount(payments.total_week_kopeks / 100)} {currencySymbol} +

+
+
+
+ + {/* Desktop Table */} +
+ + + + + + + + + + + + {payments.payments.slice(0, 10).map((payment) => ( + + + + + + + + ))} + +
ПользовательТипСуммаМетодДата
+
{payment.display_name}
+ {payment.username &&
@{payment.username}
} +
+ + {payment.type_display} + + + {formatAmount(payment.amount_rubles)} {currencySymbol} + + {payment.payment_method || '-'} + + + {new Date(payment.created_at).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+ + {/* Mobile Cards */} +
+ {payments.payments.slice(0, 10).map((payment) => ( +
+
+
+ + {payment.type_display} + + {payment.display_name} +
+ + {formatAmount(payment.amount_rubles)} {currencySymbol} + +
+
+ {payment.payment_method || '-'} + + {new Date(payment.created_at).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+ ))} +
+
+ )}
) } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index ab10f97..3708998 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,339 +1,365 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' -// Icons - smaller versions for mobile -const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - +// Group header icons +const AnalyticsGroupIcon = () => ( + + ) -const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const UsersGroupIcon = () => ( + + + +) + +const TariffsGroupIcon = () => ( + + + +) + +const MarketingGroupIcon = () => ( + + + +) + +const SystemGroupIcon = () => ( + ) -const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +// Modern icons with consistent styling +const ChartBarIcon = () => ( + ) -const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - - - -) - -const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const BanknotesIcon = () => ( + ) -const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const UsersIcon = () => ( + + + +) + +const ChatBubbleIcon = () => ( + + + +) + +const NoSymbolIcon = () => ( + + + +) + +const CreditCardIcon = () => ( + + + +) + +const TicketIcon = () => ( + + + +) + +const GiftIcon = () => ( + ) -const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( - +const MegaphoneIcon = () => ( + + + +) + +const PaperAirplaneIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CogIcon = () => ( + + + + +) + +const DevicePhoneMobileIcon = () => ( + + + +) + +const ServerStackIcon = () => ( + ) +const CubeTransparentIcon = () => ( + + + +) + const ChevronRightIcon = () => ( - + ) -interface AdminSection { +interface AdminItem { to: string icon: React.ReactNode - mobileIcon: React.ReactNode title: string description: string - color: string - bgColor: string - textColor: string } -// Mobile compact card -function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) { +interface AdminGroup { + id: string + title: string + icon: React.ReactNode + gradient: string + borderColor: string + iconBg: string + iconColor: string + items: AdminItem[] +} + +function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) { return ( -
-
- {mobileIcon} -
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+
- {title} ) } -// Desktop card -function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) { +function GroupSection({ group }: { group: AdminGroup }) { return ( - -
-
- {icon} +
+ {/* Group Header */} +
+
+
+ {group.icon} +
+

{group.title}

-
-

{title}

-

{description}

+ + {/* Group Items */} +
+ {group.items.map((item) => ( + + ))}
- - +
) } export default function AdminPanel() { const { t } = useTranslation() - const adminSections: AdminSection[] = [ + const groups: AdminGroup[] = [ { - to: '/admin/dashboard', - icon: , - mobileIcon: , - title: t('admin.nav.dashboard'), - description: t('admin.panel.dashboardDesc'), - color: 'success', - bgColor: 'bg-emerald-500/20', - textColor: 'text-emerald-400' + id: 'analytics', + title: t('admin.groups.analytics', 'Аналитика'), + icon: , + gradient: 'from-emerald-500/10 to-emerald-500/5', + borderColor: 'border-emerald-500/20', + iconBg: 'bg-emerald-500/20', + iconColor: 'text-emerald-400', + items: [ + { + to: '/admin/dashboard', + icon: , + title: t('admin.nav.dashboard'), + description: t('admin.panel.dashboardDesc'), + }, + { + to: '/admin/payments', + icon: , + title: t('admin.nav.payments', 'Платежи'), + description: t('admin.panel.paymentsDesc', 'История и проверка платежей'), + }, + ], }, { - to: '/admin/tickets', - icon: , - mobileIcon: , - title: t('admin.nav.tickets'), - description: t('admin.panel.ticketsDesc'), - color: 'warning', - bgColor: 'bg-amber-500/20', - textColor: 'text-amber-400' + id: 'users', + title: t('admin.groups.users', 'Пользователи'), + icon: , + gradient: 'from-blue-500/10 to-blue-500/5', + borderColor: 'border-blue-500/20', + iconBg: 'bg-blue-500/20', + iconColor: 'text-blue-400', + items: [ + { + to: '/admin/users', + icon: , + title: t('admin.nav.users', 'Пользователи'), + description: t('admin.panel.usersDesc', 'Управление пользователями'), + }, + { + to: '/admin/tickets', + icon: , + title: t('admin.nav.tickets'), + description: t('admin.panel.ticketsDesc'), + }, + { + to: '/admin/ban-system', + icon: , + title: t('admin.nav.banSystem'), + description: t('admin.panel.banSystemDesc'), + }, + ], }, { - to: '/admin/settings', - icon: , - mobileIcon: , - title: t('admin.nav.settings'), - description: t('admin.panel.settingsDesc'), - color: 'accent', - bgColor: 'bg-blue-500/20', - textColor: 'text-blue-400' + id: 'tariffs', + title: t('admin.groups.tariffs', 'Тарифы и продажи'), + icon: , + gradient: 'from-amber-500/10 to-amber-500/5', + borderColor: 'border-amber-500/20', + iconBg: 'bg-amber-500/20', + iconColor: 'text-amber-400', + items: [ + { + to: '/admin/tariffs', + icon: , + title: t('admin.nav.tariffs'), + description: t('admin.panel.tariffsDesc'), + }, + { + to: '/admin/promocodes', + icon: , + title: t('admin.nav.promocodes', 'Промокоды'), + description: t('admin.panel.promocodesDesc', 'Управление промокодами'), + }, + { + to: '/admin/promo-offers', + icon: , + title: t('admin.nav.promoOffers', 'Промопредложения'), + description: t('admin.panel.promoOffersDesc', 'Персональные скидки'), + }, + ], }, { - to: '/admin/apps', - icon: , - mobileIcon: , - title: t('admin.nav.apps'), - description: t('admin.panel.appsDesc'), - color: 'success', - bgColor: 'bg-teal-500/20', - textColor: 'text-teal-400' + id: 'marketing', + title: t('admin.groups.marketing', 'Маркетинг'), + icon: , + gradient: 'from-rose-500/10 to-rose-500/5', + borderColor: 'border-rose-500/20', + iconBg: 'bg-rose-500/20', + iconColor: 'text-rose-400', + items: [ + { + to: '/admin/campaigns', + icon: , + title: t('admin.nav.campaigns', 'Кампании'), + description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), + }, + { + to: '/admin/broadcasts', + icon: , + title: t('admin.nav.broadcasts'), + description: t('admin.panel.broadcastsDesc'), + }, + { + to: '/admin/wheel', + icon: , + title: t('admin.nav.wheel'), + description: t('admin.panel.wheelDesc'), + }, + ], }, { - to: '/admin/wheel', - icon: , - mobileIcon: , - title: t('admin.nav.wheel'), - description: t('admin.panel.wheelDesc'), - color: 'error', - bgColor: 'bg-rose-500/20', - textColor: 'text-rose-400' - }, - { - to: '/admin/tariffs', - icon: , - mobileIcon: , - title: t('admin.nav.tariffs'), - description: t('admin.panel.tariffsDesc'), - color: 'info', - bgColor: 'bg-cyan-500/20', - textColor: 'text-cyan-400' - }, - { - to: '/admin/servers', - icon: , - mobileIcon: , - title: t('admin.nav.servers'), - description: t('admin.panel.serversDesc'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' - }, - { - to: '/admin/broadcasts', - icon: , - mobileIcon: , - title: t('admin.nav.broadcasts'), - description: t('admin.panel.broadcastsDesc'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/promocodes', - icon: , - mobileIcon: , - title: t('admin.nav.promocodes', 'Промокоды'), - description: t('admin.panel.promocodesDesc', 'Управление промокодами'), - color: 'violet', - bgColor: 'bg-violet-500/20', - textColor: 'text-violet-400' - }, - { - to: '/admin/promo-offers', - icon: , - mobileIcon: , - title: t('admin.nav.promoOffers', 'Промопредложения'), - description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/campaigns', - icon: , - mobileIcon: , - title: t('admin.nav.campaigns', 'Кампании'), - description: t('admin.panel.campaignsDesc', 'Рекламные кампании'), - color: 'orange', - bgColor: 'bg-orange-500/20', - textColor: 'text-orange-400' - }, - { - to: '/admin/users', - icon: , - mobileIcon: , - title: t('admin.nav.users', 'Пользователи'), - description: t('admin.panel.usersDesc', 'Управление пользователями'), - color: 'indigo', - bgColor: 'bg-indigo-500/20', - textColor: 'text-indigo-400' - }, - { - to: '/admin/ban-system', - icon: , - mobileIcon: , - title: t('admin.nav.banSystem'), - description: t('admin.panel.banSystemDesc'), - color: 'error', - bgColor: 'bg-red-500/20', - textColor: 'text-red-400' - }, - { - to: '/admin/payments', - icon: , - mobileIcon: , - title: t('admin.nav.payments', 'Платежи'), - description: t('admin.panel.paymentsDesc', 'Проверка платежей'), - color: 'lime', - bgColor: 'bg-lime-500/20', - textColor: 'text-lime-400' - }, - { - to: '/admin/remnawave', - icon: , - mobileIcon: , - title: t('admin.nav.remnawave', 'RemnaWave'), - description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'), - color: 'purple', - bgColor: 'bg-purple-500/20', - textColor: 'text-purple-400' + id: 'system', + title: t('admin.groups.system', 'Система'), + icon: , + gradient: 'from-violet-500/10 to-violet-500/5', + borderColor: 'border-violet-500/20', + iconBg: 'bg-violet-500/20', + iconColor: 'text-violet-400', + items: [ + { + to: '/admin/settings', + icon: , + title: t('admin.nav.settings'), + description: t('admin.panel.settingsDesc'), + }, + { + to: '/admin/apps', + icon: , + title: t('admin.nav.apps'), + description: t('admin.panel.appsDesc'), + }, + { + to: '/admin/servers', + icon: , + title: t('admin.nav.servers'), + description: t('admin.panel.serversDesc'), + }, + { + to: '/admin/remnawave', + icon: , + title: t('admin.nav.remnawave', 'RemnaWave'), + description: t('admin.panel.remnawaveDesc', 'Управление панелью'), + }, + ], }, ] return ( -
- {/* Header - compact on mobile */} -
-
- -
-
-

{t('admin.panel.title')}

-

{t('admin.panel.subtitle')}

-
+
+ {/* Header */} +
+

{t('admin.panel.title')}

+

{t('admin.panel.subtitle')}

- {/* Mobile: Compact 2-column grid */} -
- {adminSections.map((section) => ( - - ))} -
- - {/* Tablet/Desktop: List style */} -
- {adminSections.map((section) => ( - + {/* Groups Grid */} +
+ {groups.map((group) => ( + ))}
diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx index ab7122d..3d5255b 100644 --- a/src/pages/AdminPromocodes.tsx +++ b/src/pages/AdminPromocodes.tsx @@ -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(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 }: +
@@ -262,6 +270,40 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
)} + {type === 'discount' && ( + <> +
+ +
+ 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} + /> + % +
+

Скидка применяется один раз при оплате

+
+
+ +
+ 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} + /> + часов +
+

Сколько часов после активации скидка действует

+
+ + )} + {/* Max Uses */}
@@ -578,6 +620,18 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal +{promocode.subscription_days}
)} + {promocode.type === 'discount' && ( + <> +
+ Скидка: + -{promocode.balance_bonus_kopeks}% +
+
+ Действует: + {promocode.subscription_days} часов +
+ + )}
Лимит: @@ -936,6 +990,9 @@ export default function AdminPromocodes() { {(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && ( +{promo.subscription_days} дней )} + {promo.type === 'discount' && ( + -{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч + )} Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index e8cfa56..6080066 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -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() {

+ + {/* Animation Toggle */} +
+
+
+

Анимированный фон

+

+ Волновая анимация на фоне для всех пользователей +

+
+ +
+
{/* Theme Colors Card */} diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index 7c7d514..eb7a2ee 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -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(tariff?.allowed_squads || []) - const [serverTrafficLimits, setServerTrafficLimits] = useState>( - 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(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 = {} - 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' && (

- Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого. + Выберите серверы, доступные на этом тарифе.

{servers.length === 0 ? (

Нет доступных серверов

) : ( servers.map(server => { const isSelected = selectedSquads.includes(server.squad_uuid) - const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0 return (
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' }`} > -
toggleServer(server.squad_uuid)} - className="flex items-center gap-3 cursor-pointer" - > +
{server.country_code} )}
- {isSelected && ( -
- Лимит трафика: - 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" - /> - ГБ - {serverLimit === 0 && ( - (использовать общий) - )} -
- )}
) }) @@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri )}
+ {/* Плавающий тариф - произвольное количество дней */} +
+
+
+

Произвольное количество дней

+

Пользователь сам выбирает срок подписки

+
+ +
+ {customDaysEnabled && ( +
+
+ Цена за день: + 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} + /> + +
+
+ Мин. дней: + 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} + /> +
+
+ Макс. дней: + 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} + /> +
+
+ )} +
+ + {/* Плавающий тариф - произвольный трафик */} +
+
+
+

Произвольный объём трафика

+

Пользователь сам выбирает объём трафика при покупке

+
+ +
+ {customTrafficEnabled && ( +
+
+ Цена за 1 ГБ: + 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} + /> + +
+
+ Мин. ГБ: + 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} + /> +
+
+ Макс. ГБ: + 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} + /> +
+
+ )} +
+ {/* Режим сброса трафика */}

Режим сброса трафика

@@ -636,7 +732,7 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri