mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -12,6 +12,7 @@ export interface AnimationEnabled {
|
||||
}
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
|
||||
|
||||
// Get cached branding from localStorage
|
||||
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 = {
|
||||
// Get current branding (public, no auth required)
|
||||
getBranding: async (): Promise<BrandingInfo> => {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 {
|
||||
@@ -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)
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
|
||||
const [isLowPerf] = useState(() => isLowPerformance())
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
staleTime: 1000 * 60, // 1 minute - быстрее обновляется при изменении админом
|
||||
refetchOnWindowFocus: true, // Обновить при возврате в окно
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@@ -47,184 +61,26 @@ export default function AnimatedBackground() {
|
||||
}
|
||||
}, [animationSettings])
|
||||
|
||||
// Don't render anything until we know the state (from cache or server)
|
||||
// If cache says disabled, don't render
|
||||
// If cache is null (first visit), default to not showing to avoid flash
|
||||
if (isEnabled !== true) {
|
||||
// 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 (
|
||||
<>
|
||||
{/* 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 */}
|
||||
<div className="wave-bg-container">
|
||||
<div className="wave-blob wave-blob-1" />
|
||||
<div className="wave-blob wave-blob-2" />
|
||||
<div className="wave-blob wave-blob-3" />
|
||||
<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 className="wave-bg-container">
|
||||
<div className="wave-blob wave-blob-1" />
|
||||
<div className="wave-blob wave-blob-2" />
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className="wave-blob wave-blob-3" />
|
||||
<div className="wave-blob wave-blob-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default AnimatedBackground
|
||||
|
||||
@@ -9,11 +9,12 @@ import TicketNotificationBell from '../TicketNotificationBell'
|
||||
import AnimatedBackground from '../AnimatedBackground'
|
||||
import { contestsApi } from '../../api/contests'
|
||||
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 { 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'
|
||||
@@ -122,6 +123,18 @@ const WheelIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FullscreenIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ExitFullscreenIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
@@ -129,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp()
|
||||
|
||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||
const { data: enabledThemes } = useQuery({
|
||||
@@ -167,12 +181,17 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
|
||||
// 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: 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
|
||||
@@ -286,11 +305,19 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
||||
{/* Logo */}
|
||||
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0">
|
||||
{hasCustomLogo && logoUrl ? (
|
||||
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-white font-bold text-lg sm:text-xl lg:text-2xl">{logoLetter}</span>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{logoLetter}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
{hasCustomLogo && logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
@@ -339,6 +366,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Fullscreen toggle - only show in Telegram WebApp */}
|
||||
{isTelegramWebApp && isFullscreenSupported && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
|
||||
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
|
||||
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Theme toggle button - only show if both themes are enabled */}
|
||||
{canToggle && (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react'
|
||||
import type { WheelPrize } from '../../api/wheel'
|
||||
|
||||
interface FortuneWheelProps {
|
||||
@@ -8,7 +8,14 @@ interface FortuneWheelProps {
|
||||
onSpinComplete: () => void
|
||||
}
|
||||
|
||||
export default function FortuneWheel({
|
||||
// Pre-generate sparkle positions to avoid recalculating on each render
|
||||
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
||||
top: `${20 + (i * 10) % 60}%`,
|
||||
left: `${15 + (i * 13) % 70}%`,
|
||||
delay: `${i * 0.15}s`,
|
||||
}))
|
||||
|
||||
const FortuneWheel = memo(function FortuneWheel({
|
||||
prizes,
|
||||
isSpinning,
|
||||
targetRotation,
|
||||
@@ -16,17 +23,17 @@ export default function FortuneWheel({
|
||||
}: FortuneWheelProps) {
|
||||
const wheelRef = useRef<SVGGElement>(null)
|
||||
const [currentRotation, setCurrentRotation] = useState(0)
|
||||
const [lightPattern, setLightPattern] = useState<boolean[]>([])
|
||||
const [lightPhase, setLightPhase] = useState(0)
|
||||
|
||||
// Animated lights effect
|
||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
||||
useEffect(() => {
|
||||
if (isSpinning) {
|
||||
const interval = setInterval(() => {
|
||||
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4))
|
||||
}, 100)
|
||||
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
|
||||
}, 250) // Slower interval = better performance
|
||||
return () => clearInterval(interval)
|
||||
} else {
|
||||
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0))
|
||||
setLightPhase(0)
|
||||
}
|
||||
}, [isSpinning])
|
||||
|
||||
@@ -42,6 +49,14 @@ export default function FortuneWheel({
|
||||
}
|
||||
}, [isSpinning, targetRotation, onSpinComplete])
|
||||
|
||||
// Memoize light pattern calculation
|
||||
const lightPattern = useMemo(() => {
|
||||
return Array.from({ length: 20 }, (_, i) => {
|
||||
if (!isSpinning) return i % 2 === 0
|
||||
return (i + lightPhase) % 3 !== 0
|
||||
})
|
||||
}, [isSpinning, lightPhase])
|
||||
|
||||
if (prizes.length === 0) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
||||
@@ -416,19 +431,19 @@ export default function FortuneWheel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sparkle effects when spinning */}
|
||||
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
||||
{isSpinning && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
{SPARKLE_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={`sparkle-${i}`}
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full"
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
|
||||
style={{
|
||||
top: `${15 + Math.random() * 70}%`,
|
||||
left: `${15 + Math.random() * 70}%`,
|
||||
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`,
|
||||
animationDelay: `${i * 0.12}s`,
|
||||
opacity: 0.8,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
animationDelay: pos.delay,
|
||||
animationDuration: '1.5s',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -436,4 +451,6 @@ export default function FortuneWheel({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default FortuneWheel
|
||||
|
||||
113
src/hooks/useTelegramWebApp.ts
Normal file
113
src/hooks/useTelegramWebApp.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,117 +100,90 @@ function generatePalette(baseHex: string): ColorPalette {
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate neutral palette from dark background color (for dark theme)
|
||||
function generateDarkPalette(darkBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(darkBgHex)
|
||||
|
||||
// Use very low saturation for neutral colors
|
||||
const neutralS = Math.min(s, 15)
|
||||
|
||||
// Lightness values - from very light (50) to very dark (950)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 97,
|
||||
100: 96,
|
||||
200: 89,
|
||||
300: 80,
|
||||
400: 58,
|
||||
500: 40,
|
||||
600: 28,
|
||||
700: 20,
|
||||
800: 12,
|
||||
850: 10,
|
||||
900: 7,
|
||||
950: 4,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50
|
||||
const { r, g, b } = hslToRgb(h, neutralS, lightness)
|
||||
palette[shade as keyof ColorPalette] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate light theme palette (champagne-like)
|
||||
function generateLightPalette(lightBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(lightBgHex)
|
||||
|
||||
// Lightness values for light theme - inverse of dark
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 100,
|
||||
100: 98,
|
||||
200: 91,
|
||||
300: 83,
|
||||
400: 74,
|
||||
500: 64,
|
||||
600: 55,
|
||||
700: 42,
|
||||
800: 31,
|
||||
900: 21,
|
||||
950: 10,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
const lightness = lightnessMap[shade]
|
||||
const { r, g, b } = hslToRgb(h, s, lightness)
|
||||
palette[shade] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
// Interpolate between two RGB colors
|
||||
function interpolateRgb(
|
||||
rgb1: { r: number; g: number; b: number },
|
||||
rgb2: { r: number; g: number; b: number },
|
||||
factor: number
|
||||
): string {
|
||||
return rgbToString(
|
||||
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
const root = document.documentElement
|
||||
|
||||
// Generate palettes from base colors
|
||||
// Generate palettes from status colors
|
||||
const accentPalette = generatePalette(colors.accent)
|
||||
const successPalette = generatePalette(colors.success)
|
||||
const warningPalette = generatePalette(colors.warning)
|
||||
const errorPalette = generatePalette(colors.error)
|
||||
|
||||
// Generate dark/light palettes from background colors
|
||||
const darkPalette = generateDarkPalette(colors.darkBackground)
|
||||
const champagnePalette = generateLightPalette(colors.lightBackground)
|
||||
// === DARK THEME PALETTE ===
|
||||
// Convert hex colors to RGB
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground)
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
|
||||
const darkTextRgb = hexToRgb(colors.darkText)
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
|
||||
|
||||
// Apply dark palette
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
if (darkPalette[shade as keyof ColorPalette]) {
|
||||
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette])
|
||||
}
|
||||
}
|
||||
// Apply dark palette with actual user colors:
|
||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
|
||||
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
|
||||
|
||||
// Apply champagne/light palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade])
|
||||
}
|
||||
// Transition colors (500-700): interpolate between secondary text and surface
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
|
||||
|
||||
// Apply accent palette
|
||||
// Surface/card colors (800-850): surface color
|
||||
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
|
||||
|
||||
// Background colors (900-950): background color
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
|
||||
|
||||
// === LIGHT THEME PALETTE ===
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground)
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
|
||||
const lightTextRgb = hexToRgb(colors.lightText)
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
|
||||
|
||||
// Apply champagne palette with actual user colors:
|
||||
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
||||
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
|
||||
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
|
||||
|
||||
// Transition colors (500-600): between bg and text
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
|
||||
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
|
||||
|
||||
// Text colors (700-950): secondary to primary text
|
||||
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
|
||||
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
|
||||
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
|
||||
// === STATUS COLOR PALETTES ===
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
||||
}
|
||||
|
||||
// Apply success palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
||||
}
|
||||
|
||||
// Apply warning palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
||||
}
|
||||
|
||||
// Apply error palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
||||
}
|
||||
|
||||
// Apply semantic colors (hex for direct use in some places)
|
||||
// Apply semantic colors (hex for direct use)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground)
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface)
|
||||
root.style.setProperty('--color-dark-text', colors.darkText)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
TariffCreateRequest,
|
||||
TariffUpdateRequest,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit
|
||||
ServerInfo
|
||||
} from '../api/tariffs'
|
||||
|
||||
// Icons
|
||||
@@ -146,9 +145,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
tariff?.period_prices?.length ? tariff.period_prices : []
|
||||
)
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
|
||||
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
|
||||
tariff?.server_traffic_limits || {}
|
||||
)
|
||||
// Докупка трафика
|
||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
|
||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
|
||||
@@ -159,6 +155,18 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
// Режим сброса трафика
|
||||
const [trafficResetMode, setTrafficResetMode] = useState<string | null>(tariff?.traffic_reset_mode || null)
|
||||
|
||||
// Плавающий тариф - произвольное количество дней
|
||||
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
|
||||
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
|
||||
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
|
||||
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
|
||||
|
||||
// Плавающий тариф - произвольный трафик
|
||||
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
|
||||
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
|
||||
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
|
||||
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
|
||||
|
||||
// Новый период для добавления
|
||||
const [newPeriodDays, setNewPeriodDays] = useState(30)
|
||||
const [newPeriodPrice, setNewPeriodPrice] = useState(300)
|
||||
@@ -166,13 +174,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const filteredLimits: Record<string, ServerTrafficLimit> = {}
|
||||
for (const uuid of selectedSquads) {
|
||||
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
|
||||
filteredLimits[uuid] = serverTrafficLimits[uuid]
|
||||
}
|
||||
}
|
||||
|
||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -183,24 +184,25 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
tier_level: tierLevel,
|
||||
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
|
||||
allowed_squads: selectedSquads,
|
||||
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: maxTopupTrafficGb,
|
||||
is_daily: false,
|
||||
daily_price_kopeks: 0,
|
||||
traffic_reset_mode: trafficResetMode,
|
||||
// Плавающий тариф
|
||||
custom_days_enabled: customDaysEnabled,
|
||||
price_per_day_kopeks: pricePerDayKopeks,
|
||||
min_days: minDays,
|
||||
max_days: maxDays,
|
||||
custom_traffic_enabled: customTrafficEnabled,
|
||||
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
|
||||
min_traffic_gb: minTrafficGb,
|
||||
max_traffic_gb: maxTrafficGb,
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
|
||||
setServerTrafficLimits(prev => ({
|
||||
...prev,
|
||||
[uuid]: { traffic_limit_gb: limitGb }
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
setSelectedSquads(prev =>
|
||||
prev.includes(uuid)
|
||||
@@ -430,27 +432,24 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-dark-400 mb-4">
|
||||
Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого.
|
||||
Выберите серверы, доступные на этом тарифе.
|
||||
</p>
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-dark-500 text-center py-4">Нет доступных серверов</p>
|
||||
) : (
|
||||
servers.map(server => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid)
|
||||
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`p-3 rounded-lg transition-colors ${
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-accent-500/20 border border-accent-500/50'
|
||||
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-500 text-white'
|
||||
@@ -463,27 +462,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<span className="text-xs text-dark-400">Лимит трафика:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={serverLimit}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => {
|
||||
e.stopPropagation()
|
||||
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-xs text-dark-400">ГБ</span>
|
||||
{serverLimit === 0 && (
|
||||
<span className="text-xs text-dark-500">(использовать общий)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Плавающий тариф - произвольное количество дней */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">Произвольное количество дней</h4>
|
||||
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает срок подписки</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
customDaysEnabled ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{customDaysEnabled && (
|
||||
<div className="space-y-3 pt-2 border-t border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pricePerDayKopeks / 100}
|
||||
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minDays}
|
||||
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDays}
|
||||
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Плавающий тариф - произвольный трафик */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">Произвольный объём трафика</h4>
|
||||
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает объём трафика при покупке</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
customTrafficEnabled ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{customTrafficEnabled && (
|
||||
<div className="space-y-3 pt-2 border-t border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={trafficPricePerGbKopeks / 100}
|
||||
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minTrafficGb}
|
||||
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTrafficGb}
|
||||
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Режим сброса трафика */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-dark-200 mb-3">Режим сброса трафика</h4>
|
||||
@@ -636,7 +732,7 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || periodPrices.length === 0 || isLoading}
|
||||
disabled={!name || (periodPrices.length === 0 && !customDaysEnabled) || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
@@ -668,9 +764,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
|
||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
|
||||
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
|
||||
tariff?.server_traffic_limits || {}
|
||||
)
|
||||
// Докупка трафика
|
||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
|
||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
|
||||
@@ -684,13 +777,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'servers' | 'extra'>('basic')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const filteredLimits: Record<string, ServerTrafficLimit> = {}
|
||||
for (const uuid of selectedSquads) {
|
||||
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
|
||||
filteredLimits[uuid] = serverTrafficLimits[uuid]
|
||||
}
|
||||
}
|
||||
|
||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -701,7 +787,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
tier_level: tierLevel,
|
||||
period_prices: [],
|
||||
allowed_squads: selectedSquads,
|
||||
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: maxTopupTrafficGb,
|
||||
@@ -712,13 +797,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
|
||||
setServerTrafficLimits(prev => ({
|
||||
...prev,
|
||||
[uuid]: { traffic_limit_gb: limitGb }
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
setSelectedSquads(prev =>
|
||||
prev.includes(uuid)
|
||||
@@ -870,20 +948,17 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
) : (
|
||||
servers.map(server => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid)
|
||||
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`p-3 rounded-lg transition-colors ${
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-amber-500 text-white'
|
||||
@@ -896,26 +971,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<span className="text-xs text-dark-400">Лимит трафика:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={serverLimit}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => {
|
||||
e.stopPropagation()
|
||||
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-amber-500"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-xs text-dark-400">ГБ</span>
|
||||
{serverLimit === 0 && (
|
||||
<span className="text-xs text-dark-500">(использовать общий)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,15 +32,33 @@ const ChevronRightIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SupportLottieIcon = () => (
|
||||
<div className="w-6 h-6">
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
|
||||
loop
|
||||
autoplay
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// Check if device might be low-performance (Telegram WebApp on mobile)
|
||||
const isLowPerfDevice = (() => {
|
||||
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
return isTelegramWebApp && isMobile
|
||||
})()
|
||||
|
||||
const SupportLottieIcon = () => {
|
||||
// Use static icon on low-performance devices
|
||||
if (isLowPerfDevice) {
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-6 h-6">
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
|
||||
loop
|
||||
autoplay
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -3,47 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { brandingApi, type BrandingInfo } from '../api/branding'
|
||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, type BrandingInfo } from '../api/branding'
|
||||
import { getAndClearReturnUrl } from '../utils/token'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher'
|
||||
import TelegramLoginButton from '../components/TelegramLoginButton'
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet-branding-cache'
|
||||
const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
|
||||
const getCachedBranding = (): BrandingInfo | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(BRANDING_CACHE_KEY)
|
||||
if (!raw) return undefined
|
||||
const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number }
|
||||
if (!parsed?.data || !parsed.timestamp) return undefined
|
||||
if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) {
|
||||
localStorage.removeItem(BRANDING_CACHE_KEY)
|
||||
return undefined
|
||||
}
|
||||
return parsed.data
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const cacheBranding = (data: BrandingInfo) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
BRANDING_CACHE_KEY,
|
||||
JSON.stringify({ data, timestamp: Date.now() })
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage errors (e.g., private mode)
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -55,6 +19,7 @@ export default function Login() {
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
|
||||
const [logoLoaded, setLogoLoaded] = useState(false)
|
||||
|
||||
// Получаем URL для возврата после авторизации
|
||||
const getReturnUrl = useCallback(() => {
|
||||
@@ -72,22 +37,21 @@ export default function Login() {
|
||||
return '/'
|
||||
}, [location.state])
|
||||
|
||||
// Fetch branding
|
||||
// Fetch branding with unified cache
|
||||
const cachedBranding = useMemo(() => getCachedBranding(), [])
|
||||
|
||||
const { data: branding } = useQuery<BrandingInfo>({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding()
|
||||
setCachedBranding(data)
|
||||
preloadLogo(data)
|
||||
return data
|
||||
},
|
||||
staleTime: 60000,
|
||||
placeholderData: cachedBranding,
|
||||
initialData: cachedBranding ?? undefined,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (branding) {
|
||||
cacheBranding(branding)
|
||||
}
|
||||
}, [branding])
|
||||
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
|
||||
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
|
||||
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
||||
@@ -168,11 +132,19 @@ export default function Login() {
|
||||
<div className="relative max-w-md w-full space-y-8">
|
||||
{/* Logo */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
|
||||
{branding?.has_custom_logo && logoUrl ? (
|
||||
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-white font-bold text-2xl">{appLogo}</span>
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden relative">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-white font-bold text-2xl absolute transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{appLogo}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
{branding?.has_custom_logo && logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-cover absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
|
||||
@@ -6,6 +6,15 @@ import FortuneWheel from '../components/wheel/FortuneWheel'
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
// Pre-calculated confetti positions (stable across re-renders)
|
||||
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
|
||||
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
||||
left: `${(i * 17 + 5) % 95}%`,
|
||||
top: `${(i * 23 + 3) % 90}%`,
|
||||
delay: `${(i * 0.1) % 2}s`,
|
||||
duration: `${1 + (i % 3) * 0.3}s`,
|
||||
}))
|
||||
|
||||
// Icons
|
||||
const StarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -98,10 +107,12 @@ export default function Wheel() {
|
||||
}, [config, isTelegramMiniApp])
|
||||
|
||||
// Function to poll for new spin result after Stars payment
|
||||
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
|
||||
const pollForSpinResult = useCallback(async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
|
||||
// Wait a bit before first poll to give the bot time to process the payment
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
if (signal.aborted) return null
|
||||
|
||||
// Get current history to find the latest spin ID
|
||||
let historyBefore
|
||||
try {
|
||||
@@ -112,8 +123,12 @@ export default function Wheel() {
|
||||
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (signal.aborted) return null
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
|
||||
if (signal.aborted) return null
|
||||
|
||||
try {
|
||||
const historyAfter = await wheelApi.getHistory(1, 1)
|
||||
|
||||
@@ -152,6 +167,16 @@ export default function Wheel() {
|
||||
// Ref to store pending Stars payment result
|
||||
const pendingStarsResultRef = useRef<SpinResult | null>(null)
|
||||
const isStarsSpinRef = useRef(false)
|
||||
const pollingAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingAbortRef.current) {
|
||||
pollingAbortRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const starsInvoiceMutation = useMutation({
|
||||
mutationFn: wheelApi.createStarsInvoice,
|
||||
@@ -163,6 +188,12 @@ export default function Wheel() {
|
||||
isStarsSpinRef.current = true
|
||||
pendingStarsResultRef.current = null
|
||||
|
||||
// Cancel any existing polling
|
||||
if (pollingAbortRef.current) {
|
||||
pollingAbortRef.current.abort()
|
||||
}
|
||||
pollingAbortRef.current = new AbortController()
|
||||
|
||||
// Payment done - reset paying state immediately
|
||||
setIsPayingStars(false)
|
||||
|
||||
@@ -172,7 +203,10 @@ export default function Wheel() {
|
||||
|
||||
// Poll for the result in the background - don't await here!
|
||||
// The result will be stored and shown when animation completes
|
||||
pollForSpinResult().then((result) => {
|
||||
const abortSignal = pollingAbortRef.current.signal
|
||||
pollForSpinResult(abortSignal).then((result) => {
|
||||
if (abortSignal.aborted) return
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
|
||||
|
||||
@@ -195,6 +229,8 @@ export default function Wheel() {
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortSignal.aborted) return
|
||||
|
||||
// Error polling, show generic success
|
||||
pendingStarsResultRef.current = {
|
||||
success: true,
|
||||
@@ -551,7 +587,7 @@ export default function Wheel() {
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: '200% 100%',
|
||||
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
|
||||
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Button glow effect */}
|
||||
@@ -669,18 +705,18 @@ export default function Wheel() {
|
||||
<>
|
||||
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
|
||||
{/* Confetti effect */}
|
||||
{/* Confetti effect - using pre-calculated positions */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
{CONFETTI_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full animate-bounce"
|
||||
style={{
|
||||
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
animationDuration: `${1 + Math.random()}s`,
|
||||
background: pos.color,
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
animationDelay: pos.delay,
|
||||
animationDuration: pos.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -747,12 +783,6 @@ export default function Wheel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1003,6 +1003,11 @@
|
||||
}
|
||||
|
||||
/* Fortune Wheel animations */
|
||||
@keyframes wheel-shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
|
||||
@keyframes wheel-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
|
||||
@@ -1050,3 +1055,99 @@
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */
|
||||
.wave-bg-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: strict; /* Performance: isolate repaints */
|
||||
}
|
||||
|
||||
.wave-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px); /* Reduced from 80px for better performance */
|
||||
opacity: 0.5;
|
||||
/* GPU acceleration */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Smaller sizes for better performance */
|
||||
.wave-blob-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%);
|
||||
top: -15%;
|
||||
left: -10%;
|
||||
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
|
||||
}
|
||||
|
||||
.wave-blob-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%);
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
animation: wave2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-blob-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%);
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation: wave3 30s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-blob-4 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%);
|
||||
bottom: 25%;
|
||||
left: 15%;
|
||||
animation: wave4 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Simplified keyframes - fewer steps = better performance */
|
||||
@keyframes wave1 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(8%, 12%, 0) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes wave2 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(-10%, -8%, 0) scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes wave3 {
|
||||
0%, 100% { transform: translate3d(-50%, -50%, 0) scale(1); }
|
||||
50% { transform: translate3d(-45%, -55%, 0) scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes wave4 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(12%, -8%, 0) scale(1.12); }
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
.light .wave-blob {
|
||||
opacity: 0.6;
|
||||
filter: blur(70px);
|
||||
}
|
||||
|
||||
.light .wave-blob-1 {
|
||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Disable animations for reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wave-blob {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/vite-env.d.ts
vendored
23
src/vite-env.d.ts
vendored
@@ -25,12 +25,35 @@ interface TelegramWebApp {
|
||||
auth_date: number
|
||||
hash: string
|
||||
}
|
||||
version: string
|
||||
platform: string
|
||||
isExpanded: boolean
|
||||
isClosingConfirmationEnabled: boolean
|
||||
isVerticalSwipesEnabled: boolean
|
||||
isFullscreen: boolean
|
||||
isOrientationLocked: boolean
|
||||
safeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||
ready: () => void
|
||||
expand: () => void
|
||||
close: () => void
|
||||
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
|
||||
openTelegramLink: (url: string) => void
|
||||
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
|
||||
// Fullscreen API (Bot API 8.0+)
|
||||
requestFullscreen: () => void
|
||||
exitFullscreen: () => void
|
||||
lockOrientation: () => void
|
||||
unlockOrientation: () => void
|
||||
// Vertical swipes control
|
||||
disableVerticalSwipes: () => void
|
||||
enableVerticalSwipes: () => void
|
||||
// Closing confirmation
|
||||
enableClosingConfirmation: () => void
|
||||
disableClosingConfirmation: () => void
|
||||
// Event handlers
|
||||
onEvent: (eventType: string, callback: () => void) => void
|
||||
offEvent: (eventType: string, callback: () => void) => void
|
||||
MainButton: {
|
||||
text: string
|
||||
color: string
|
||||
|
||||
Reference in New Issue
Block a user