From 8cc03851a861427dffb5fed08e7740c37e7283d0 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:02:16 +0300 Subject: [PATCH 1/2] Add files via upload --- src/components/PromoDiscountBadge.tsx | 152 ++++ src/components/layout/Layout.tsx | 1019 ++++++++++--------------- 2 files changed, 548 insertions(+), 623 deletions(-) create mode 100644 src/components/PromoDiscountBadge.tsx 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 */} + +
+ ) } From 386acef1763ec86711272b6638c775810c2e9abe Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:03:11 +0300 Subject: [PATCH 2/2] Add files via upload --- src/pages/Subscription.tsx | 471 +++++++++++++++++++++++++------------ 1 file changed, 321 insertions(+), 150 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 3617ec2..f1d37e5 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router-dom' import { AxiosError } from 'axios' import { subscriptionApi } from '../api/subscription' +import { promoApi } from '../api/promo' import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions } from '../types' import ConnectionModal from '../components/ConnectionModal' import { useCurrency } from '../hooks/useCurrency' @@ -55,6 +56,24 @@ export default function Subscription() { // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}` + // Helper to apply promo discount to a price + const applyPromoDiscount = (priceKopeks: number, hasExistingDiscount: boolean = false): { + price: number; + original: number | null; + percent: number | null + } => { + // Only apply promo discount if no existing discount (from promo group) and we have an active promo discount + if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) { + return { price: priceKopeks, original: null, percent: null } + } + const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100)) + return { + price: discountedPrice, + original: priceKopeks, + percent: activeDiscount.discount_percent + } + } + // Purchase state (classic mode) const [currentStep, setCurrentStep] = useState('period') const [selectedPeriod, setSelectedPeriod] = useState(null) @@ -91,6 +110,13 @@ export default function Subscription() { queryFn: subscriptionApi.getPurchaseOptions, }) + // Fetch active promo discount + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + }) + // Check if in tariffs mode (moved up to be available for useEffect) const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs' const classicOptions = !isTariffsMode ? purchaseOptions as ClassicPurchaseOptions : null @@ -1116,16 +1142,25 @@ export default function Subscription() { // Daily tariff price (is_daily + daily_price_kopeks) const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0 const originalDailyPrice = tariff.original_daily_price_kopeks || 0 + const hasExistingDailyDiscount = originalDailyPrice > dailyPrice if (dailyPrice > 0) { + // Apply promo discount if no existing discount + const promoDaily = applyPromoDiscount(dailyPrice, hasExistingDailyDiscount) return ( - {formatPrice(dailyPrice)} - {originalDailyPrice > dailyPrice && ( - {formatPrice(originalDailyPrice)} + {formatPrice(promoDaily.price)} + {/* Show original price from promo group or promo offer */} + {(hasExistingDailyDiscount || promoDaily.original) && ( + + {formatPrice(hasExistingDailyDiscount ? originalDailyPrice : promoDaily.original!)} + )} / день - {tariff.daily_discount_percent && tariff.daily_discount_percent > 0 && ( + {/* Show discount badge */} + {(tariff.daily_discount_percent && tariff.daily_discount_percent > 0) ? ( -{tariff.daily_discount_percent}% + ) : promoDaily.percent && ( + -{promoDaily.percent}% )} ) @@ -1133,18 +1168,24 @@ export default function Subscription() { // Period-based price if (tariff.periods.length > 0) { const firstPeriod = tariff.periods[0] - const hasDiscount = firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks + const hasExistingDiscount = !!(firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks) + // Apply promo discount if no existing discount + const promoPeriod = applyPromoDiscount(firstPeriod?.price_kopeks || 0, hasExistingDiscount) return ( {t('subscription.from')} - {formatPrice(firstPeriod?.price_kopeks || 0)} - {hasDiscount && ( - <> - {formatPrice(firstPeriod.original_price_kopeks!)} - {firstPeriod.discount_percent && ( - -{firstPeriod.discount_percent}% - )} - + {formatPrice(promoPeriod.price)} + {/* Show original price from promo group or promo offer */} + {(hasExistingDiscount || promoPeriod.original) && ( + + {formatPrice(hasExistingDiscount ? firstPeriod.original_price_kopeks! : promoPeriod.original!)} + + )} + {/* Show discount badge */} + {hasExistingDiscount && firstPeriod.discount_percent ? ( + -{firstPeriod.discount_percent}% + ) : promoPeriod.percent && ( + -{promoPeriod.percent}% )} ) @@ -1330,34 +1371,47 @@ export default function Subscription() { {/* Fixed periods */} {selectedTariff.periods.length > 0 && !useCustomDays && (
- {selectedTariff.periods.map((period) => ( -
-
{formatPrice(period.price_per_month_kopeks)}/{t('subscription.month')}
- - ))} +
{period.label}
+
+ {formatPrice(displayPrice)} + {displayOriginal && displayOriginal > displayPrice && ( + {formatPrice(displayOriginal)} + )} +
+
{formatPrice(displayPerMonth)}/{t('subscription.month')}
+ + ) + })} )} @@ -1400,10 +1454,25 @@ export default function Subscription() { className="w-20 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 text-center" /> -
- {customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день - {formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))} -
+ {(() => { + const basePrice = customDays * (selectedTariff.price_per_day_kopeks ?? 0) + const hasExistingDiscount = !!(selectedTariff.original_price_per_day_kopeks && selectedTariff.original_price_per_day_kopeks > (selectedTariff.price_per_day_kopeks ?? 0)) + const promoCustom = applyPromoDiscount(basePrice, hasExistingDiscount) + return ( +
+ {customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день +
+ {formatPrice(promoCustom.price)} + {promoCustom.original && ( + <> + {formatPrice(promoCustom.original)} + -{promoCustom.percent}% + + )} +
+
+ ) + })()} )} @@ -1472,42 +1541,76 @@ export default function Subscription() { {/* Summary & Purchase */} {(selectedTariffPeriod || useCustomDays) && (
- {/* Price breakdown */} -
- {useCustomDays ? ( -
- Период: {customDays} дней - {formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))} -
- ) : selectedTariffPeriod && ( -
- Период: {selectedTariffPeriod.label} - {formatPrice(selectedTariffPeriod.price_kopeks)} -
- )} - {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( -
- Трафик: {customTrafficGb} ГБ - +{formatPrice(customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0))} -
- )} -
- {(() => { - const periodPrice = useCustomDays + // Calculate prices with promo discount + const basePeriodPrice = useCustomDays ? customDays * (selectedTariff.price_per_day_kopeks ?? 0) : (selectedTariffPeriod?.price_kopeks || 0) + const hasExistingPeriodDiscount = !useCustomDays && selectedTariffPeriod?.original_price_kopeks + ? selectedTariffPeriod.original_price_kopeks > selectedTariffPeriod.price_kopeks + : false + const promoPeriod = applyPromoDiscount(basePeriodPrice, hasExistingPeriodDiscount) + const trafficPrice = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0) : 0 - const totalPrice = periodPrice + trafficPrice + + const totalPrice = promoPeriod.price + trafficPrice + const originalTotal = promoPeriod.original ? promoPeriod.original + trafficPrice : null const hasEnoughBalance = purchaseOptions && totalPrice <= purchaseOptions.balance_kopeks return ( <> + {/* Price breakdown */} +
+ {useCustomDays ? ( +
+ Период: {customDays} дней +
+ {formatPrice(promoPeriod.price)} + {promoPeriod.original && ( + {formatPrice(promoPeriod.original)} + )} +
+
+ ) : selectedTariffPeriod && ( +
+ Период: {selectedTariffPeriod.label} +
+ {formatPrice(promoPeriod.price)} + {(hasExistingPeriodDiscount || promoPeriod.original) && ( + + {formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)} + + )} +
+
+ )} + {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( +
+ Трафик: {customTrafficGb} ГБ + +{formatPrice(trafficPrice)} +
+ )} +
+ + {/* Promo discount info */} + {promoPeriod.percent && ( +
+ + Скидка -{promoPeriod.percent}% применена + +
+ )} +
{t('subscription.total')} - {formatPrice(totalPrice)} +
+ {formatPrice(totalPrice)} + {originalTotal && ( +
{formatPrice(originalTotal)}
+ )} +
{purchaseOptions && !hasEnoughBalance && ( @@ -1593,93 +1696,139 @@ export default function Subscription() { {/* Step: Period Selection */} {currentStep === 'period' && classicOptions && (
- {classicOptions.periods.map((period) => ( - - ))} + {classicOptions.periods.map((period) => { + const hasExistingDiscount = !!(period.discount_percent && period.discount_percent > 0) + const promoPeriod = applyPromoDiscount(period.price_kopeks, hasExistingDiscount) + const displayDiscount = hasExistingDiscount ? period.discount_percent : promoPeriod.percent + const displayOriginal = hasExistingDiscount ? period.original_price_kopeks : promoPeriod.original + + return ( + + ) + })}
)} {/* Step: Traffic Selection */} {currentStep === 'traffic' && selectedPeriod?.traffic.options && (
- {selectedPeriod.traffic.options.map((option) => ( - - ))} + {selectedPeriod.traffic.options.map((option) => { + const hasExistingDiscount = !!(option.discount_percent && option.discount_percent > 0) + const promoTraffic = applyPromoDiscount(option.price_kopeks, hasExistingDiscount) + + return ( + + ) + })}
)} {/* Step: Server Selection */} {currentStep === 'servers' && selectedPeriod?.servers.options && (
- {selectedPeriod.servers.options.map((server) => ( -
- - ))} + + ) + })}
)} @@ -1723,6 +1872,18 @@ export default function Subscription() { ) : preview ? (
+ {/* Active promo discount banner */} + {activeDiscount?.is_active && activeDiscount.discount_percent && !preview.original_price_kopeks && ( +
+ + + + + Скидка -{activeDiscount.discount_percent}% применена + +
+ )} + {preview.breakdown.map((item, idx) => (
{item.label} @@ -1730,15 +1891,25 @@ export default function Subscription() {
))} -
- {t('subscription.total')} -
-
{formatPrice(preview.total_price_kopeks)}
- {preview.original_price_kopeks && ( -
{formatPrice(preview.original_price_kopeks)}
- )} -
-
+ {(() => { + // Apply promo discount if not already applied by server + const hasServerDiscount = !!preview.original_price_kopeks + const promoTotal = applyPromoDiscount(preview.total_price_kopeks, hasServerDiscount) + const displayTotal = promoTotal.price + const displayOriginal = hasServerDiscount ? preview.original_price_kopeks : promoTotal.original + + return ( +
+ {t('subscription.total')} +
+
{formatPrice(displayTotal)}
+ {displayOriginal && ( +
{formatPrice(displayOriginal)}
+ )} +
+
+ ) + })()} {preview.discount_label && (
{preview.discount_label}