import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useState, useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../../store/auth'; import LanguageSwitcher from '../LanguageSwitcher'; import PromoDiscountBadge from '../PromoDiscountBadge'; import TicketNotificationBell from '../TicketNotificationBell'; import AnimatedBackground from '../AnimatedBackground'; import { contestsApi } from '../../api/contests'; import { pollsApi } from '../../api/polls'; import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded, } from '../../api/branding'; import { wheelApi } from '../../api/wheel'; import { themeColorsApi } from '../../api/themeColors'; import { promoApi } from '../../api/promo'; import { referralApi } from '../../api/referral'; import { useTheme } from '../../hooks/useTheme'; import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'; import { usePullToRefresh } from '../../hooks/usePullToRefresh'; // Fallback branding from environment variables const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; interface LayoutProps { children: React.ReactNode; } // Icons as simple SVG components const HomeIcon = () => ( ); const SubscriptionIcon = () => ( ); const WalletIcon = () => ( ); const UsersIcon = () => ( ); const ChatIcon = () => ( ); const UserIcon = () => ( ); const LogoutIcon = () => ( ); // Theme toggle icons const SunIcon = () => ( ); const MoonIcon = () => ( ); const MenuIcon = () => ( ); const CloseIcon = () => ( ); const GamepadIcon = () => ( ); const ClipboardIcon = () => ( ); const InfoIcon = () => ( ); const CogIcon = () => ( ); const WheelIcon = () => ( ); export default function Layout({ children }: LayoutProps) { const { t } = useTranslation(); const location = useLocation(); const { user, logout, isAdmin, isAuthenticated } = useAuthStore(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); const { toggleTheme, isDark } = useTheme(); const [userPhotoUrl, setUserPhotoUrl] = useState(null); const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); // Pull to refresh (disabled when mobile menu is open) const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ disabled: mobileMenuOpen, threshold: 80, }); // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ queryKey: ['enabled-themes'], queryFn: themeColorsApi.getEnabledThemes, staleTime: 1000 * 60 * 5, // 5 minutes }); // Only show theme toggle if both themes are enabled const canToggle = enabledThemes?.dark && enabledThemes?.light; // Get user photo from Telegram WebApp useEffect(() => { try { const tg = ( window as unknown as { Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } }; } ).Telegram?.WebApp; const photoUrl = tg?.initDataUnsafe?.user?.photo_url; if (photoUrl) { setUserPhotoUrl(photoUrl); } } catch (e) { console.warn('Failed to get Telegram user photo:', e); } }, []); // Lock body scroll when mobile menu is open (cross-platform) // Note: We avoid using body position:fixed with top:-scrollY as it causes issues // in Telegram Mini App where the menu disappears when opened from scrolled position useEffect(() => { if (!mobileMenuOpen) return; const body = document.body; const html = document.documentElement; // Save original styles const originalStyles = { bodyOverflow: body.style.overflow, htmlOverflow: html.style.overflow, }; // Lock scroll - simple approach without body position manipulation body.style.overflow = 'hidden'; html.style.overflow = 'hidden'; // Prevent touchmove on body (critical for mobile, especially Telegram Mini App) const preventScroll = (e: TouchEvent) => { const target = e.target as HTMLElement; // Allow scroll inside menu content if (target.closest('.mobile-menu-content')) return; e.preventDefault(); }; document.addEventListener('touchmove', preventScroll, { passive: false }); // Also prevent wheel scroll on desktop const preventWheel = (e: WheelEvent) => { const target = e.target as HTMLElement; if (target.closest('.mobile-menu-content')) return; e.preventDefault(); }; document.addEventListener('wheel', preventWheel, { passive: false }); return () => { // Restore original styles body.style.overflow = originalStyles.bodyOverflow; html.style.overflow = originalStyles.htmlOverflow; // Remove listeners document.removeEventListener('touchmove', preventScroll); document.removeEventListener('wheel', preventWheel); }; }, [mobileMenuOpen]); // Detect virtual keyboard by tracking focus on input elements useEffect(() => { const handleFocusIn = (e: FocusEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { setIsKeyboardOpen(true); } }; const handleFocusOut = (e: FocusEvent) => { const relatedTarget = e.relatedTarget as HTMLElement | null; // Only close if not focusing another input if ( !relatedTarget || (relatedTarget.tagName !== 'INPUT' && relatedTarget.tagName !== 'TEXTAREA' && !relatedTarget.isContentEditable) ) { setIsKeyboardOpen(false); } }; document.addEventListener('focusin', handleFocusIn); document.addEventListener('focusout', handleFocusOut); return () => { document.removeEventListener('focusin', handleFocusIn); document.removeEventListener('focusout', handleFocusOut); }; }, []); // State to track if logo image has loaded - start with true if already preloaded const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()); // 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 staleTime: 60000, // 1 minute refetchOnWindowFocus: true, retry: 1, }); // Computed branding values - use fallback only if no branding and no cache const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode) const logoLetter = branding?.logo_letter || FALLBACK_LOGO; const hasCustomLogo = branding?.has_custom_logo || false; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; // Set document title useEffect(() => { document.title = appName || 'VPN'; // Fallback title if name is empty }, [appName]); // Update favicon useEffect(() => { if (!logoUrl) return; const link = document.querySelector("link[rel*='icon']") || document.createElement('link'); link.type = 'image/x-icon'; link.rel = 'shortcut icon'; link.href = logoUrl; document.getElementsByTagName('head')[0].appendChild(link); }, [logoUrl]); // Fetch contests and polls counts to determine if they should be shown const { data: contestsCount } = useQuery({ queryKey: ['contests-count'], queryFn: contestsApi.getCount, enabled: isAuthenticated, staleTime: 60000, // 1 minute retry: false, }); const { data: pollsCount } = useQuery({ queryKey: ['polls-count'], queryFn: pollsApi.getCount, enabled: isAuthenticated, staleTime: 60000, // 1 minute retry: false, }); // Fetch wheel config to check if enabled const { data: wheelConfig } = useQuery({ queryKey: ['wheel-config'], queryFn: wheelApi.getConfig, enabled: isAuthenticated, staleTime: 60000, // 1 minute retry: false, }); // Fetch referral terms to check if enabled const { data: referralTerms } = useQuery({ queryKey: ['referral-terms'], queryFn: referralApi.getReferralTerms, enabled: isAuthenticated, staleTime: 60000, // 1 minute retry: false, }); // Fetch active discount to determine mobile layout const { data: activeDiscount } = useQuery({ queryKey: ['active-discount'], queryFn: promoApi.getActiveDiscount, enabled: isAuthenticated, staleTime: 30000, }); // Check if promo is active (to hide language switcher on mobile) const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent; const navItems = useMemo(() => { const items = [ { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, ]; // Only show referral if program is enabled if (referralTerms?.is_enabled) { items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon }); } items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon }); // Only show contests if there are available contests if (contestsCount && contestsCount.count > 0) { items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }); } // Only show polls if there are available polls if (pollsCount && pollsCount.count > 0) { items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }); } items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }); return items; }, [t, contestsCount, pollsCount, referralTerms]); // Separate navItems for desktop that includes wheel (if enabled) const desktopNavItems = useMemo(() => { const items = [...navItems]; // Add wheel before info if enabled if (wheelConfig?.is_enabled) { const infoIndex = items.findIndex((item) => item.path === '/info'); if (infoIndex !== -1) { items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }); } else { items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }); } } return items; }, [navItems, wheelConfig, t]); const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }]; const isActive = (path: string) => location.pathname === path; const isAdminActive = () => location.pathname.startsWith('/admin'); return (
{/* Animated Background */} {/* Pull to refresh indicator */} {(isPulling || isRefreshing) && (
)} {/* Header */}
mobileMenuOpen && setMobileMenuOpen(false)} >
{/* Logo */} setMobileMenuOpen(false)} className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`} >
{/* Always show letter as fallback */} {logoLetter} {/* Logo image with smooth fade-in */} {hasCustomLogo && logoUrl && ( {appName setLogoLoaded(true)} /> )}
{appName && ( {appName} )} {/* Desktop Navigation */} {/* Right side */}
{/* Theme toggle button - only show if both themes are enabled */} {canToggle && ( )}
setMobileMenuOpen(false)}>
setMobileMenuOpen(false)}>
{/* Hide language switcher on mobile when promo is active */}
setMobileMenuOpen(false)} >
{/* Profile - Desktop */}
{user?.first_name || user?.username || `#${user?.telegram_id}`}
{/* Mobile menu button */}
{/* Spacer for fixed header - matches header height */} {isFullscreen ? (
) : (
)} {/* Mobile menu - fixed overlay below header */} {mobileMenuOpen && (
{/* Backdrop */}
setMobileMenuOpen(false)} /> {/* Menu content */}
{/* User info */}
{userPhotoUrl ? ( Avatar { e.currentTarget.style.display = 'none'; e.currentTarget.nextElementSibling?.classList.remove('hidden'); }} /> ) : null}
{user?.first_name || user?.username}
@{user?.username || `ID: ${user?.telegram_id}`}
{/* Language switcher in mobile menu when promo is active */} {isPromoActive && }
{/* Nav items */}
)} {/* Main Content */}
{children}
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
); }