Merge pull request #45 from BEDOLAGA-DEV/main

ц
This commit is contained in:
Egor
2026-01-20 01:24:52 +03:00
committed by GitHub
13 changed files with 704 additions and 461 deletions

View File

@@ -12,6 +12,7 @@ export interface AnimationEnabled {
} }
const BRANDING_CACHE_KEY = 'cabinet_branding' const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
// Get cached branding from localStorage // Get cached branding from localStorage
export const getCachedBranding = (): BrandingInfo | null => { export const getCachedBranding = (): BrandingInfo | null => {
@@ -35,6 +36,41 @@ export const setCachedBranding = (branding: BrandingInfo) => {
} }
} }
// 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 = { export const brandingApi = {
// Get current branding (public, no auth required) // Get current branding (public, no auth required)
getBranding: async (): Promise<BrandingInfo> => { getBranding: async (): Promise<BrandingInfo> => {

View File

@@ -1,9 +1,21 @@
import { useEffect, useState } from 'react' import { useEffect, useState, memo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding' import { brandingApi } from '../api/branding'
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' 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 // Get cached value from localStorage
const getCachedAnimationEnabled = (): boolean | null => { const getCachedAnimationEnabled = (): boolean | null => {
try { try {
@@ -26,15 +38,17 @@ export const setCachedAnimationEnabled = (enabled: boolean) => {
} }
} }
export default function AnimatedBackground() { // Memoized background component to prevent re-renders
const AnimatedBackground = memo(function AnimatedBackground() {
// Start with cached value (null means unknown yet) // Start with cached value (null means unknown yet)
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled()) const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
const [isLowPerf] = useState(() => isLowPerformance())
const { data: animationSettings } = useQuery({ const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'], queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled, queryFn: brandingApi.getAnimationEnabled,
staleTime: 1000 * 60, // 1 minute - быстрее обновляется при изменении админом staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
refetchOnWindowFocus: true, // Обновить при возврате в окно refetchOnWindowFocus: false, // Don't refetch on focus - save resources
retry: false, retry: false,
}) })
@@ -47,184 +61,26 @@ export default function AnimatedBackground() {
} }
}, [animationSettings]) }, [animationSettings])
// Don't render anything until we know the state (from cache or server) // Don't render if disabled or on low-performance devices
// If cache says disabled, don't render if (isEnabled !== true || isLowPerf) {
// If cache is null (first visit), default to not showing to avoid flash
if (isEnabled !== true) {
return null return null
} }
return ( // Render only 2 blobs on mobile for better performance
<> const isMobile = window.innerWidth < 768
{/* SVG Filter for glow effect */}
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</svg>
{/* Animated wave gradients */} return (
<div className="wave-bg-container"> <div className="wave-bg-container">
<div className="wave-blob wave-blob-1" /> <div className="wave-blob wave-blob-1" />
<div className="wave-blob wave-blob-2" /> <div className="wave-blob wave-blob-2" />
{!isMobile && (
<>
<div className="wave-blob wave-blob-3" /> <div className="wave-blob wave-blob-3" />
<div className="wave-blob wave-blob-4" /> <div className="wave-blob wave-blob-4" />
</div>
<style>{`
.wave-bg-container {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
background: transparent;
}
.wave-blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
mix-blend-mode: screen;
}
.wave-blob-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(249, 115, 22, 0.8) 0%, transparent 70%);
top: -20%;
left: -10%;
animation: wave1 15s ease-in-out infinite;
}
.wave-blob-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%);
bottom: -15%;
right: -10%;
animation: wave2 18s ease-in-out infinite;
}
.wave-blob-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.6) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: wave3 20s ease-in-out infinite;
}
.wave-blob-4 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.5) 0%, transparent 70%);
bottom: 20%;
left: 20%;
animation: wave4 12s ease-in-out infinite;
}
@keyframes wave1 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(10%, 15%) scale(1.1);
}
50% {
transform: translate(5%, 25%) scale(0.95);
}
75% {
transform: translate(-5%, 10%) scale(1.05);
}
}
@keyframes wave2 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(-15%, -10%) scale(1.15);
}
50% {
transform: translate(-10%, -20%) scale(0.9);
}
75% {
transform: translate(5%, -5%) scale(1.1);
}
}
@keyframes wave3 {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
33% {
transform: translate(-40%, -60%) scale(1.2);
}
66% {
transform: translate(-60%, -40%) scale(0.85);
}
}
@keyframes wave4 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20%, -15%) scale(1.25);
}
}
/* Dark theme - brighter */
:root.dark .wave-blob {
opacity: 0.5;
}
:root.dark .wave-blob-1 {
background: radial-gradient(circle, rgba(249, 115, 22, 0.7) 0%, transparent 70%);
}
:root.dark .wave-blob-2 {
background: radial-gradient(circle, rgba(59, 130, 246, 0.7) 0%, transparent 70%);
}
:root.dark .wave-blob-3 {
background: radial-gradient(circle, rgba(168, 85, 247, 0.5) 0%, transparent 70%);
}
:root.dark .wave-blob-4 {
background: radial-gradient(circle, rgba(236, 72, 153, 0.4) 0%, transparent 70%);
}
/* Light theme - visible */
:root:not(.dark) .wave-blob {
opacity: 0.7;
mix-blend-mode: multiply;
filter: blur(100px);
}
:root:not(.dark) .wave-blob-1 {
background: radial-gradient(circle, rgba(249, 115, 22, 0.9) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-2 {
background: radial-gradient(circle, rgba(59, 130, 246, 0.9) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-3 {
background: radial-gradient(circle, rgba(168, 85, 247, 0.7) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-4 {
background: radial-gradient(circle, rgba(236, 72, 153, 0.6) 0%, transparent 70%);
}
`}</style>
</> </>
)}
</div>
) )
} })
export default AnimatedBackground

View File

@@ -9,11 +9,12 @@ import TicketNotificationBell from '../TicketNotificationBell'
import AnimatedBackground from '../AnimatedBackground' import AnimatedBackground from '../AnimatedBackground'
import { contestsApi } from '../../api/contests' import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls' import { pollsApi } from '../../api/polls'
import { brandingApi, getCachedBranding, setCachedBranding } from '../../api/branding' import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo } from '../../api/branding'
import { wheelApi } from '../../api/wheel' import { wheelApi } from '../../api/wheel'
import { themeColorsApi } from '../../api/themeColors' import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo' import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme' import { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
// Fallback branding from environment variables // Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -122,6 +123,18 @@ const WheelIcon = () => (
</svg> </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) { export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation() const { t } = useTranslation()
const location = useLocation() const location = useLocation()
@@ -129,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme() const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null) const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp()
// Fetch enabled themes from API - same source of truth as AdminSettings // Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
@@ -167,12 +181,17 @@ export default function Layout({ children }: LayoutProps) {
} }
}, [mobileMenuOpen]) }, [mobileMenuOpen])
// State to track if logo image has loaded
const [logoLoaded, setLogoLoaded] = useState(false)
// Fetch branding settings with localStorage cache for instant load // Fetch branding settings with localStorage cache for instant load
const { data: branding } = useQuery({ const { data: branding } = useQuery({
queryKey: ['branding'], queryKey: ['branding'],
queryFn: async () => { queryFn: async () => {
const data = await brandingApi.getBranding() const data = await brandingApi.getBranding()
setCachedBranding(data) // Update cache setCachedBranding(data) // Update cache
// Preload logo in background
preloadLogo(data)
return data return data
}, },
initialData: getCachedBranding() ?? undefined, // Use cached data immediately initialData: getCachedBranding() ?? undefined, // Use cached data immediately
@@ -286,11 +305,19 @@ export default function Layout({ children }: LayoutProps) {
<div className="flex justify-between items-center h-16 lg:h-20"> <div className="flex justify-between items-center h-16 lg:h-20">
{/* Logo */} {/* Logo */}
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}> <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"> <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">
{hasCustomLogo && logoUrl ? ( {/* Always show letter as fallback */}
<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 absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
) : ( {logoLetter}
<span className="text-white font-bold text-lg sm:text-xl lg:text-2xl">{logoLetter}</span> </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> </div>
{appName && ( {appName && (
@@ -339,6 +366,20 @@ export default function Layout({ children }: LayoutProps) {
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-2 sm:gap-3"> <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 */} {/* Theme toggle button - only show if both themes are enabled */}
{canToggle && ( {canToggle && (
<button <button

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' import type { WheelPrize } from '../../api/wheel'
interface FortuneWheelProps { interface FortuneWheelProps {
@@ -8,7 +8,14 @@ interface FortuneWheelProps {
onSpinComplete: () => void 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, prizes,
isSpinning, isSpinning,
targetRotation, targetRotation,
@@ -16,17 +23,17 @@ export default function FortuneWheel({
}: FortuneWheelProps) { }: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null) const wheelRef = useRef<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0) 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(() => { useEffect(() => {
if (isSpinning) { if (isSpinning) {
const interval = setInterval(() => { const interval = setInterval(() => {
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4)) setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
}, 100) }, 250) // Slower interval = better performance
return () => clearInterval(interval) return () => clearInterval(interval)
} else { } else {
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0)) setLightPhase(0)
} }
}, [isSpinning]) }, [isSpinning])
@@ -42,6 +49,14 @@ export default function FortuneWheel({
} }
}, [isSpinning, targetRotation, onSpinComplete]) }, [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) { if (prizes.length === 0) {
return ( return (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center"> <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> </div>
{/* Sparkle effects when spinning */} {/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && ( {isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden"> <div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => ( {SPARKLE_POSITIONS.map((pos, i) => (
<div <div
key={`sparkle-${i}`} 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={{ style={{
top: `${15 + Math.random() * 70}%`, top: pos.top,
left: `${15 + Math.random() * 70}%`, left: pos.left,
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`, animationDelay: pos.delay,
animationDelay: `${i * 0.12}s`, animationDuration: '1.5s',
opacity: 0.8, opacity: 0.7,
}} }}
/> />
))} ))}
@@ -436,4 +451,6 @@ export default function FortuneWheel({
)} )}
</div> </div>
) )
} })
export default FortuneWheel

View File

@@ -0,0 +1,113 @@
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 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 })
// 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 })
}
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
}
}, [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,
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 return palette as ColorPalette
} }
// Generate neutral palette from dark background color (for dark theme) // Interpolate between two RGB colors
function generateDarkPalette(darkBgHex: string): ColorPalette { function interpolateRgb(
const { h, s } = hexToHsl(darkBgHex) rgb1: { r: number; g: number; b: number },
rgb2: { r: number; g: number; b: number },
// Use very low saturation for neutral colors factor: number
const neutralS = Math.min(s, 15) ): string {
return rgbToString(
// Lightness values - from very light (50) to very dark (950) Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
const lightnessMap: Record<number, number> = { Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
50: 97, Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
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
} }
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support) // Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void { export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement const root = document.documentElement
// Generate palettes from base colors // Generate palettes from status colors
const accentPalette = generatePalette(colors.accent) const accentPalette = generatePalette(colors.accent)
const successPalette = generatePalette(colors.success) const successPalette = generatePalette(colors.success)
const warningPalette = generatePalette(colors.warning) const warningPalette = generatePalette(colors.warning)
const errorPalette = generatePalette(colors.error) const errorPalette = generatePalette(colors.error)
// Generate dark/light palettes from background colors // === DARK THEME PALETTE ===
const darkPalette = generateDarkPalette(colors.darkBackground) // Convert hex colors to RGB
const champagnePalette = generateLightPalette(colors.lightBackground) const darkBgRgb = hexToRgb(colors.darkBackground)
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
const darkTextRgb = hexToRgb(colors.darkText)
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
// Apply dark palette // Apply dark palette with actual user colors:
for (const shade of [...SHADE_LEVELS, 850] as const) { // Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
if (darkPalette[shade as keyof ColorPalette]) { root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette]) 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 // Transition colors (500-700): interpolate between secondary text and surface
for (const shade of SHADE_LEVELS) { root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade]) 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) { for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]) 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]) 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]) 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]) 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-bg', colors.darkBackground)
root.style.setProperty('--color-dark-surface', colors.darkSurface) root.style.setProperty('--color-dark-surface', colors.darkSurface)
root.style.setProperty('--color-dark-text', colors.darkText) 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 App from './App'
import { ThemeColorsProvider } from './providers/ThemeColorsProvider' import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
import { ToastProvider } from './components/Toast' import { ToastProvider } from './components/Toast'
import { initLogoPreload } from './api/branding'
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
import './i18n' import './i18n'
import './styles/globals.css' import './styles/globals.css'
// Initialize Telegram WebApp (expand, disable swipes)
initTelegramWebApp()
// Preload logo from cache immediately on page load
initLogoPreload()
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

View File

@@ -9,8 +9,7 @@ import {
TariffCreateRequest, TariffCreateRequest,
TariffUpdateRequest, TariffUpdateRequest,
PeriodPrice, PeriodPrice,
ServerInfo, ServerInfo
ServerTrafficLimit
} from '../api/tariffs' } from '../api/tariffs'
// Icons // Icons
@@ -146,9 +145,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
tariff?.period_prices?.length ? tariff.period_prices : [] tariff?.period_prices?.length ? tariff.period_prices : []
) )
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || []) 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 [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0) 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 [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 [newPeriodDays, setNewPeriodDays] = useState(30)
const [newPeriodPrice, setNewPeriodPrice] = useState(300) 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 [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic')
const handleSubmit = () => { 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 = { const data: TariffCreateRequest | TariffUpdateRequest = {
name, name,
description: description || undefined, description: description || undefined,
@@ -183,24 +184,25 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
tier_level: tierLevel, tier_level: tierLevel,
period_prices: periodPrices.filter(p => p.price_kopeks > 0), period_prices: periodPrices.filter(p => p.price_kopeks > 0),
allowed_squads: selectedSquads, allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
traffic_topup_enabled: trafficTopupEnabled, traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages, traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb, max_topup_traffic_gb: maxTopupTrafficGb,
is_daily: false, is_daily: false,
daily_price_kopeks: 0, daily_price_kopeks: 0,
traffic_reset_mode: trafficResetMode, 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) onSave(data)
} }
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => { const toggleServer = (uuid: string) => {
setSelectedSquads(prev => setSelectedSquads(prev =>
prev.includes(uuid) prev.includes(uuid)
@@ -430,27 +432,24 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
{activeTab === 'servers' && ( {activeTab === 'servers' && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-dark-400 mb-4"> <p className="text-sm text-dark-400 mb-4">
Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого. Выберите серверы, доступные на этом тарифе.
</p> </p>
{servers.length === 0 ? ( {servers.length === 0 ? (
<p className="text-dark-500 text-center py-4">Нет доступных серверов</p> <p className="text-dark-500 text-center py-4">Нет доступных серверов</p>
) : ( ) : (
servers.map(server => { servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid) const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return ( return (
<div <div
key={server.id} 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 isSelected
? 'bg-accent-500/20 border border-accent-500/50' ? 'bg-accent-500/20 border border-accent-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent' : 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`} }`}
> >
<div <div className="flex items-center gap-3">
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className={`w-5 h-5 rounded flex items-center justify-center ${ <div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected isSelected
? 'bg-accent-500 text-white' ? '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> <span className="text-xs text-dark-500">{server.country_code}</span>
)} )}
</div> </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> </div>
) )
}) })
@@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
)} )}
</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={() => 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"> <div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-200 mb-3">Режим сброса трафика</h4> <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>
<button <button
onClick={handleSubmit} 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" 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 ? 'Сохранение...' : 'Сохранить'} {isLoading ? 'Сохранение...' : 'Сохранить'}
@@ -668,9 +764,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1) const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0) const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || []) 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 [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0) 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 [activeTab, setActiveTab] = useState<'basic' | 'servers' | 'extra'>('basic')
const handleSubmit = () => { 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 = { const data: TariffCreateRequest | TariffUpdateRequest = {
name, name,
description: description || undefined, description: description || undefined,
@@ -701,7 +787,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
tier_level: tierLevel, tier_level: tierLevel,
period_prices: [], period_prices: [],
allowed_squads: selectedSquads, allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
traffic_topup_enabled: trafficTopupEnabled, traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages, traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb, max_topup_traffic_gb: maxTopupTrafficGb,
@@ -712,13 +797,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
onSave(data) onSave(data)
} }
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => { const toggleServer = (uuid: string) => {
setSelectedSquads(prev => setSelectedSquads(prev =>
prev.includes(uuid) prev.includes(uuid)
@@ -870,20 +948,17 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
) : ( ) : (
servers.map(server => { servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid) const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return ( return (
<div <div
key={server.id} 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 isSelected
? 'bg-amber-500/20 border border-amber-500/50' ? 'bg-amber-500/20 border border-amber-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent' : 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`} }`}
> >
<div <div className="flex items-center gap-3">
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className={`w-5 h-5 rounded flex items-center justify-center ${ <div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected isSelected
? 'bg-amber-500 text-white' ? '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> <span className="text-xs text-dark-500">{server.country_code}</span>
)} )}
</div> </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> </div>
) )
}) })

View File

@@ -32,7 +32,24 @@ const ChevronRightIcon = () => (
</svg> </svg>
) )
const SupportLottieIcon = () => ( // 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"> <div className="w-6 h-6">
<DotLottieReact <DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie" src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
@@ -41,6 +58,7 @@ const SupportLottieIcon = () => (
/> />
</div> </div>
) )
}
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -3,47 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth' 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 { getAndClearReturnUrl } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher' import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton' 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() { export default function Login() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -55,6 +19,7 @@ export default function Login() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [logoLoaded, setLogoLoaded] = useState(false)
// Получаем URL для возврата после авторизации // Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => { const getReturnUrl = useCallback(() => {
@@ -72,22 +37,21 @@ export default function Login() {
return '/' return '/'
}, [location.state]) }, [location.state])
// Fetch branding // Fetch branding with unified cache
const cachedBranding = useMemo(() => getCachedBranding(), []) const cachedBranding = useMemo(() => getCachedBranding(), [])
const { data: branding } = useQuery<BrandingInfo>({ const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'], queryKey: ['branding'],
queryFn: brandingApi.getBranding, queryFn: async () => {
const data = await brandingApi.getBranding()
setCachedBranding(data)
preloadLogo(data)
return data
},
staleTime: 60000, staleTime: 60000,
placeholderData: cachedBranding, initialData: cachedBranding ?? undefined,
}) })
useEffect(() => {
if (branding) {
cacheBranding(branding)
}
}, [branding])
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '' const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN') const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V' const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
@@ -168,11 +132,19 @@ export default function Login() {
<div className="relative max-w-md w-full space-y-8"> <div className="relative max-w-md w-full space-y-8">
{/* Logo */} {/* Logo */}
<div className="text-center"> <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"> <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">
{branding?.has_custom_logo && logoUrl ? ( {/* Always show letter as fallback */}
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" /> <span className={`text-white font-bold text-2xl absolute transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
) : ( {appLogo}
<span className="text-white font-bold text-2xl">{appLogo}</span> </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> </div>
{appName && ( {appName && (

View File

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

View File

@@ -1003,6 +1003,11 @@
} }
/* Fortune Wheel animations */ /* Fortune Wheel animations */
@keyframes wheel-shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
@keyframes wheel-glow { @keyframes wheel-glow {
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); } 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)); } 50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
@@ -1050,3 +1055,99 @@
from { width: 100%; } from { width: 100%; }
to { width: 0%; } 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;
}
}

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

@@ -25,12 +25,35 @@ interface TelegramWebApp {
auth_date: number auth_date: number
hash: string 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 ready: () => void
expand: () => void expand: () => void
close: () => void close: () => void
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
openTelegramLink: (url: string) => void openTelegramLink: (url: string) => void
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => 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: { MainButton: {
text: string text: string
color: string color: string