diff --git a/src/api/branding.ts b/src/api/branding.ts index 0067175..8d21793 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -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 => { + 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 => { 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/layout/Layout.tsx b/src/components/layout/Layout.tsx index 01cfd90..e7af97f 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -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 = () => ( ) +const FullscreenIcon = () => ( + + + +) + +const ExitFullscreenIcon = () => ( + + + +) + 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(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) {
{/* Logo */} -
- {hasCustomLogo && logoUrl ? ( - {appName - ) : ( - {logoLetter} +
+ {/* Always show letter as fallback */} + + {logoLetter} + + {/* Logo image with smooth fade-in */} + {hasCustomLogo && logoUrl && ( + {appName setLogoLoaded(true)} + /> )}
{appName && ( @@ -339,6 +366,20 @@ export default function Layout({ children }: LayoutProps) { {/* Right side */}
+ {/* Fullscreen toggle - only show in Telegram WebApp */} + {isTelegramWebApp && isFullscreenSupported && ( + + )} + {/* Theme toggle button - only show if both themes are enabled */} {canToggle && ( +
+ {customDaysEnabled && ( +
+
+ Цена за день: + setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + step={0.1} + /> + +
+
+ Мин. дней: + setMinDays(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ Макс. дней: + setMaxDays(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ )} +
+ + {/* Плавающий тариф - произвольный трафик */} +
+
+
+

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

+

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

+
+ +
+ {customTrafficEnabled && ( +
+
+ Цена за 1 ГБ: + setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={0} + step={0.1} + /> + +
+
+ Мин. ГБ: + setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ Макс. ГБ: + setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} + className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" + min={1} + /> +
+
+ )} +
+ {/* Режим сброса трафика */}

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

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