mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user