From c96ec5ecf60dc81af64e2d8002568d05068b3f9a Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 00:02:28 +0300 Subject: [PATCH] Enhance performance and user experience in AnimatedBackground and FortuneWheel components. Added low-performance device detection, optimized rendering logic, and improved CSS animations. Refactored theme color application in useThemeColors hook for better palette generation. Updated Dashboard to use static icons on low-performance devices. --- src/components/AnimatedBackground.tsx | 216 +++++--------------------- src/components/wheel/FortuneWheel.tsx | 49 ++++-- src/hooks/useThemeColors.ts | 153 ++++++++---------- src/pages/Dashboard.tsx | 36 +++-- src/styles/globals.css | 96 ++++++++++++ 5 files changed, 255 insertions(+), 295 deletions(-) diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index 8f0508b..fe54b1b 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -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(() => 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 */} - - - - - - - - - - - {/* Animated wave gradients */} -
-
-
-
-
-
- - - +
+
+
+ {!isMobile && ( + <> +
+
+ + )} +
) -} +}) + +export default AnimatedBackground diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index ed9b2fd..e25e232 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useMemo, memo } from 'react' import type { WheelPrize } from '../../api/wheel' interface FortuneWheelProps { @@ -8,7 +8,14 @@ interface FortuneWheelProps { onSpinComplete: () => void } -export default function FortuneWheel({ +// Pre-generate sparkle positions to avoid recalculating on each render +const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({ + top: `${20 + (i * 10) % 60}%`, + left: `${15 + (i * 13) % 70}%`, + delay: `${i * 0.15}s`, +})) + +const FortuneWheel = memo(function FortuneWheel({ prizes, isSpinning, targetRotation, @@ -16,17 +23,17 @@ export default function FortuneWheel({ }: FortuneWheelProps) { const wheelRef = useRef(null) const [currentRotation, setCurrentRotation] = useState(0) - const [lightPattern, setLightPattern] = useState([]) + const [lightPhase, setLightPhase] = useState(0) - // Animated lights effect + // Animated lights effect - use phase instead of random array (less re-renders) useEffect(() => { if (isSpinning) { const interval = setInterval(() => { - setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4)) - }, 100) + setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2 + }, 250) // Slower interval = better performance return () => clearInterval(interval) } else { - setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0)) + setLightPhase(0) } }, [isSpinning]) @@ -42,6 +49,14 @@ export default function FortuneWheel({ } }, [isSpinning, targetRotation, onSpinComplete]) + // Memoize light pattern calculation + const lightPattern = useMemo(() => { + return Array.from({ length: 20 }, (_, i) => { + if (!isSpinning) return i % 2 === 0 + return (i + lightPhase) % 3 !== 0 + }) + }, [isSpinning, lightPhase]) + if (prizes.length === 0) { return (
@@ -416,19 +431,19 @@ export default function FortuneWheel({ )}
- {/* Sparkle effects when spinning */} + {/* Sparkle effects when spinning - optimized with pre-calculated positions */} {isSpinning && (
- {Array.from({ length: 12 }).map((_, i) => ( + {SPARKLE_POSITIONS.map((pos, i) => (
))} @@ -436,4 +451,6 @@ export default function FortuneWheel({ )}
) -} +}) + +export default FortuneWheel diff --git a/src/hooks/useThemeColors.ts b/src/hooks/useThemeColors.ts index f0fa4eb..4fc5837 100644 --- a/src/hooks/useThemeColors.ts +++ b/src/hooks/useThemeColors.ts @@ -100,117 +100,90 @@ function generatePalette(baseHex: string): ColorPalette { return palette as ColorPalette } -// Generate neutral palette from dark background color (for dark theme) -function generateDarkPalette(darkBgHex: string): ColorPalette { - const { h, s } = hexToHsl(darkBgHex) - - // Use very low saturation for neutral colors - const neutralS = Math.min(s, 15) - - // Lightness values - from very light (50) to very dark (950) - const lightnessMap: Record = { - 50: 97, - 100: 96, - 200: 89, - 300: 80, - 400: 58, - 500: 40, - 600: 28, - 700: 20, - 800: 12, - 850: 10, - 900: 7, - 950: 4, - } - - const palette: Partial = {} - - for (const shade of [...SHADE_LEVELS, 850] as const) { - const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50 - const { r, g, b } = hslToRgb(h, neutralS, lightness) - palette[shade as keyof ColorPalette] = rgbToString(r, g, b) - } - - return palette as ColorPalette -} - -// Generate light theme palette (champagne-like) -function generateLightPalette(lightBgHex: string): ColorPalette { - const { h, s } = hexToHsl(lightBgHex) - - // Lightness values for light theme - inverse of dark - const lightnessMap: Record = { - 50: 100, - 100: 98, - 200: 91, - 300: 83, - 400: 74, - 500: 64, - 600: 55, - 700: 42, - 800: 31, - 900: 21, - 950: 10, - } - - const palette: Partial = {} - - for (const shade of SHADE_LEVELS) { - const lightness = lightnessMap[shade] - const { r, g, b } = hslToRgb(h, s, lightness) - palette[shade] = rgbToString(r, g, b) - } - - return palette as ColorPalette +// Interpolate between two RGB colors +function interpolateRgb( + rgb1: { r: number; g: number; b: number }, + rgb2: { r: number; g: number; b: number }, + factor: number +): string { + return rgbToString( + Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor), + Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor), + Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor) + ) } // Apply theme colors as CSS variables (RGB format for Tailwind opacity support) export function applyThemeColors(colors: ThemeColors): void { const root = document.documentElement - // Generate palettes from base colors + // Generate palettes from status colors const accentPalette = generatePalette(colors.accent) const successPalette = generatePalette(colors.success) const warningPalette = generatePalette(colors.warning) const errorPalette = generatePalette(colors.error) - // Generate dark/light palettes from background colors - const darkPalette = generateDarkPalette(colors.darkBackground) - const champagnePalette = generateLightPalette(colors.lightBackground) + // === DARK THEME PALETTE === + // Convert hex colors to RGB + const darkBgRgb = hexToRgb(colors.darkBackground) + const darkSurfaceRgb = hexToRgb(colors.darkSurface) + const darkTextRgb = hexToRgb(colors.darkText) + const darkTextSecRgb = hexToRgb(colors.darkTextSecondary) - // Apply dark palette - for (const shade of [...SHADE_LEVELS, 850] as const) { - if (darkPalette[shade as keyof ColorPalette]) { - root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette]) - } - } + // Apply dark palette with actual user colors: + // Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text + root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b)) + root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b)) + root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33)) + root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66)) + root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b)) - // Apply champagne/light palette - for (const shade of SHADE_LEVELS) { - root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade]) - } + // Transition colors (500-700): interpolate between secondary text and surface + root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4)) + root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6)) + root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8)) - // Apply accent palette + // Surface/card colors (800-850): surface color + root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b)) + root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5)) + + // Background colors (900-950): background color + root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7)) + root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b)) + + // === LIGHT THEME PALETTE === + const lightBgRgb = hexToRgb(colors.lightBackground) + const lightSurfaceRgb = hexToRgb(colors.lightSurface) + const lightTextRgb = hexToRgb(colors.lightText) + const lightTextSecRgb = hexToRgb(colors.lightTextSecondary) + + // Apply champagne palette with actual user colors: + // Background colors (light shades): 50-100 = surface, 200-400 = background tones + root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b)) + root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3)) + root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b)) + root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2)) + root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4)) + + // Transition colors (500-600): between bg and text + root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6)) + root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b)) + + // Text colors (700-950): secondary to primary text + root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33)) + root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66)) + root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b)) + root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b)) + + // === STATUS COLOR PALETTES === for (const shade of SHADE_LEVELS) { root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]) - } - - // Apply success palette - for (const shade of SHADE_LEVELS) { root.style.setProperty(`--color-success-${shade}`, successPalette[shade]) - } - - // Apply warning palette - for (const shade of SHADE_LEVELS) { root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]) - } - - // Apply error palette - for (const shade of SHADE_LEVELS) { root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]) } - // Apply semantic colors (hex for direct use in some places) + // Apply semantic colors (hex for direct use) root.style.setProperty('--color-dark-bg', colors.darkBackground) root.style.setProperty('--color-dark-surface', colors.darkSurface) root.style.setProperty('--color-dark-text', colors.darkText) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 317f399..46208c8 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -32,15 +32,33 @@ const ChevronRightIcon = () => ( ) -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 ( + + + + ) + } + + return ( +
+ +
+ ) +} export default function Dashboard() { const { t } = useTranslation() diff --git a/src/styles/globals.css b/src/styles/globals.css index 1c896eb..6ff0bb5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1050,3 +1050,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; + } +}