From 15c9b0321e04014b0d8b282c1182fb816e346154 Mon Sep 17 00:00:00 2001 From: evansvl Date: Sat, 17 Jan 2026 20:48:58 +0300 Subject: [PATCH] enhanced user experience for header --- src/components/layout/Layout.tsx | 1017 ++++++++++++++++++------------ 1 file changed, 623 insertions(+), 394 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index df8a1eb..a95a4cb 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -16,485 +16,714 @@ 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 + 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 { toggleTheme, isDark } = useTheme() - const [userPhotoUrl, setUserPhotoUrl] = useState(null) + const { t } = useTranslation() + const location = useLocation() + const { user, logout, isAdmin, isAuthenticated } = useAuthStore() + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const { toggleTheme, isDark } = useTheme() + const [userPhotoUrl, setUserPhotoUrl] = useState(null) - // 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 - }) + // 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 + // 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 any).Telegram?.WebApp - const photoUrl = tg?.initDataUnsafe?.user?.photo_url - if (photoUrl) { - setUserPhotoUrl(photoUrl) - } - } catch (e) { - console.warn('Failed to get Telegram user photo:', e) - } - }, []) + // Get user photo from Telegram WebApp + useEffect(() => { + try { + const tg = (window as any).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 and scroll to top when mobile menu is open - useEffect(() => { - if (mobileMenuOpen) { - // Scroll to top so header is visible - window.scrollTo({ top: 0, behavior: 'instant' }) - document.body.style.overflow = 'hidden' - } else { - document.body.style.overflow = '' - } - return () => { - document.body.style.overflow = '' - } - }, [mobileMenuOpen]) + // Lock body scroll and scroll to top when mobile menu is open + useEffect(() => { + if (mobileMenuOpen) { + // Scroll to top so header is visible + window.scrollTo({ top: 0, behavior: 'instant' }) + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [mobileMenuOpen]) - // Fetch branding settings - const { data: branding } = useQuery({ - queryKey: ['branding'], - queryFn: brandingApi.getBranding, - staleTime: 60000, // 1 minute - retry: 1, - }) + // Fetch branding settings + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: brandingApi.getBranding, + staleTime: 60000, // 1 minute + retry: 1, + }) - // Computed branding values - use fallback only if branding not loaded yet - 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 + // Computed branding values - use fallback only if branding not loaded yet + 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]) + // Set document title + useEffect(() => { + document.title = appName || 'VPN' // Fallback title if name is empty + }, [appName]) - // 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, - }) + // 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, - }) + 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 wheel config to check if enabled + const { data: wheelConfig } = useQuery({ + queryKey: ['wheel-config'], + queryFn: wheelApi.getConfig, + enabled: isAuthenticated, + staleTime: 60000, // 1 minute + retry: false, + }) - 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 }, - { path: '/referral', label: t('nav.referral'), icon: UsersIcon }, - { path: '/support', label: t('nav.support'), icon: ChatIcon }, - ] + 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 }, + { path: '/referral', label: t('nav.referral'), icon: UsersIcon }, + { 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 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 }) - } + // 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 }) + items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }) - return items - }, [t, contestsCount, pollsCount]) + return items + }, [t, contestsCount, pollsCount]) - // 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]) + // 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 adminNavItems = [ + { path: '/admin', label: t('admin.nav.title'), icon: CogIcon }, + ] - const isActive = (path: string) => location.pathname === path - const isAdminActive = () => location.pathname.startsWith('/admin') + const isActive = (path: string) => location.pathname === path + const isAdminActive = () => location.pathname.startsWith('/admin') - return ( -
- {/* Header */} -
-
-
- {/* Logo */} - -
- {hasCustomLogo && logoUrl ? ( - {appName - ) : ( - {logoLetter} - )} -
- {appName && ( - - {appName} - - )} - + return ( +
+ {/* Header */} +
+
+
+ {/* Logo */} + +
+ {hasCustomLogo && logoUrl ? ( + {appName + ) : ( + + {logoLetter} + + )} +
+ {appName && ( + + {appName} + + )} + - {/* Desktop Navigation */} - + {/* Desktop Navigation */} + - {/* Right side */} -
- {/* Theme toggle button - only show if both themes are enabled */} - {canToggle && ( - - )} + text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50' + title={ + isDark + ? t('theme.light') || 'Light mode' + : t('theme.dark') || 'Dark mode' + } + > +
+
+ +
+
+ +
+
+ + )} - + - {/* Profile - Desktop */} -
- -
- -
- - {user?.first_name || user?.username || `#${user?.telegram_id}`} - - - -
+ {/* Profile - Desktop */} +
+ +
+ +
+ + {user?.first_name || + user?.username || + `#${user?.telegram_id}`} + + + +
- {/* Mobile menu button */} - -
-
-
+ {/* Mobile menu button */} + +
+
+
+
- + {/* Mobile menu - fixed overlay */} + {mobileMenuOpen && ( +
+ {/* Backdrop */} +
setMobileMenuOpen(false)} + /> - {/* Mobile menu - fixed overlay */} - {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}`} +
+
+
- {/* 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}`} -
-
-
+ {/* Nav items */} +
+
+
+ )} - - -
-
-
- )} + {/* Main Content */} +
+
{children}
+
- {/* Main Content */} -
-
- {children} -
-
- - {/* Mobile Bottom Navigation - only core items */} - -
- ) + {/* Mobile Bottom Navigation - only core items */} + +
+ ) }