diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx new file mode 100644 index 0000000..a0d005a --- /dev/null +++ b/src/components/PromoDiscountBadge.tsx @@ -0,0 +1,152 @@ +import { useState, useRef, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { promoApi } from '../api/promo' + +const SparklesIcon = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const formatTimeLeft = (expiresAt: string): string => { + const now = new Date() + const expires = new Date(expiresAt) + const diffMs = expires.getTime() - now.getTime() + + if (diffMs <= 0) return '' + + const hours = Math.floor(diffMs / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + if (hours > 24) { + const days = Math.floor(hours / 24) + return `${days}д` + } + if (hours > 0) { + return `${hours}ч ${minutes}м` + } + return `${minutes}м` +} + +export default function PromoDiscountBadge() { + const { t } = useTranslation() + const navigate = useNavigate() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + refetchInterval: 60000, // Refresh every minute + }) + + // Close dropdown on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Don't render if no active discount + if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { + return null + } + + const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at) : null + + const handleClick = () => { + setIsOpen(!isOpen) + } + + const handleGoToSubscription = () => { + setIsOpen(false) + navigate('/subscription') + } + + return ( +
+ {/* Badge button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+
+
+ +
+
+
+ {t('promo.discountActive', 'Discount active!')} +
+
+ -{activeDiscount.discount_percent}% +
+
+
+
+ + {/* Content */} +
+

+ {t('promo.discountDescription', 'Your personal discount is applied to subscription purchases')} +

+ + {/* Time remaining */} + {timeLeft && ( +
+ + {t('promo.expiresIn', 'Expires in')}: {timeLeft} +
+ )} + + {/* Source info */} + {activeDiscount.source && ( +
+ {t('promo.source', 'Source')}: {activeDiscount.source} +
+ )} + + {/* CTA Button */} + +
+
+ )} +
+ ) +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index a95a4cb..b450eac 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -4,6 +4,7 @@ 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 { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -16,714 +17,486 @@ 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)} - /> +
- {/* 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}`} -
-
-
+ {/* Mobile menu - fixed overlay */} + {mobileMenuOpen && ( +
+ {/* Backdrop */} +
setMobileMenuOpen(false)} + /> - {/* Nav items */} - +
+
+
+ )} - {/* Mobile Bottom Navigation - only core items */} - -
- ) + {/* Main Content */} +
+
+ {children} +
+
+ + {/* Mobile Bottom Navigation - only core items */} + +
+ ) }