/** * Global success notification modal. * Shows prominent success messages for balance top-ups and subscription purchases. */ import { useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useSuccessNotification } from '../store/successNotification'; import { useCurrency } from '../hooks/useCurrency'; import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useFocusTrap } from '../hooks/useFocusTrap'; import { useHaptic } from '@/platform'; import { CheckCircleIcon, CloseIcon, DevicesIcon, RocketIcon, TrafficIcon, WalletIcon, } from '@/components/icons'; export default function SuccessNotificationModal() { const { t } = useTranslation(); const navigate = useNavigate(); const isOpen = useSuccessNotification((state) => state.isOpen); const data = useSuccessNotification((state) => state.data); const hide = useSuccessNotification((state) => state.hide); const { formatAmount, currencySymbol } = useCurrency(); const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK(); const haptic = useHaptic(); const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0; const handleClose = useCallback(() => { hide(); }, [hide]); // Esc + scroll-lock are handled by the effects below; the trap only manages focus. const modalRef = useFocusTrap(isOpen, { lockScroll: false }); // Escape key to close useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); handleClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleClose]); useEffect(() => { if (isOpen) { haptic.notification('success'); } }, [isOpen, haptic]); // Scroll lock useEffect(() => { if (!isOpen) return; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, [isOpen]); if (!isOpen || !data) return null; const isBalanceTopup = data.type === 'balance_topup'; const isSubscription = data.type === 'subscription_activated' || data.type === 'subscription_renewed' || data.type === 'subscription_purchased'; const isDevicesPurchased = data.type === 'devices_purchased'; const isTrafficPurchased = data.type === 'traffic_purchased'; // Format amount const formattedAmount = data.amountKopeks ? `${formatAmount(data.amountKopeks / 100)} ${currencySymbol}` : null; // Format new balance const formattedBalance = data.newBalanceKopeks !== undefined ? `${formatAmount(data.newBalanceKopeks / 100)} ${currencySymbol}` : null; // Format expiry date const formattedExpiry = data.expiresAt ? new Date(data.expiresAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric', }) : null; // Determine title and message let title = data.title; const message = data.message; let icon = ; let gradientClass = 'from-success-500 to-success-600'; if (!title) { if (isBalanceTopup) { title = t('successNotification.balanceTopup.title', 'Balance topped up!'); icon = ; gradientClass = 'from-success-500 to-success-600'; } else if (data.type === 'subscription_activated') { title = t('successNotification.subscriptionActivated.title', 'Subscription activated!'); icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'subscription_renewed') { title = t('successNotification.subscriptionRenewed.title', 'Subscription renewed!'); icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'subscription_purchased') { title = t('successNotification.subscriptionPurchased.title', 'Subscription purchased!'); icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'devices_purchased') { title = t('successNotification.devicesPurchased.title', 'Devices added!'); icon = ; gradientClass = 'from-blue-500 to-cyan-600'; } else if (data.type === 'traffic_purchased') { title = t('successNotification.trafficPurchased.title', 'Traffic added!'); icon = ; gradientClass = 'from-success-500 to-success-600'; } } const handleGoToSubscription = () => { hide(); navigate('/subscriptions'); }; const handleGoToBalance = () => { hide(); navigate('/balance'); }; const modalContent = (
{/* Backdrop */}
{/* Modal */}
e.stopPropagation()} > {/* Close button */} {/* Success header with animation */}
{/* Use animate-pulse for celebration; bounce easing reads dated and the lift is the moment, not the bounce. */}
{icon}

{title}

{message &&

{message}

}
{/* Details */}
{/* Amount */} {formattedAmount && (
{isBalanceTopup ? t('successNotification.amount', 'Amount') : t('successNotification.price', 'Price')} {isDevicesPurchased || isTrafficPurchased ? '' : '+'} {formattedAmount}
)} {/* Devices info (for devices purchase) */} {isDevicesPurchased && data.devicesAdded && (
{t('successNotification.devicesAdded', 'Devices added')} +{data.devicesAdded}
)} {isDevicesPurchased && data.newDeviceLimit && (
{t('successNotification.totalDevices', 'Total devices')} {data.newDeviceLimit}
)} {/* Traffic info (for traffic purchase) */} {isTrafficPurchased && data.trafficGbAdded && (
{t('successNotification.trafficAdded', 'Traffic added')} +{data.trafficGbAdded} GB
)} {isTrafficPurchased && data.newTrafficLimitGb && (
{t('successNotification.totalTraffic', 'Total traffic')} {data.newTrafficLimitGb} GB
)} {/* New balance (for top-up) */} {isBalanceTopup && formattedBalance && (
{t('successNotification.newBalance', 'New balance')} {formattedBalance}
)} {/* Tariff name */} {data.tariffName && (
{t('successNotification.tariff', 'Tariff')} {data.tariffName}
)} {/* Expiry date */} {formattedExpiry && (
{t('successNotification.validUntil', 'Valid until')} {formattedExpiry}
)} {/* Action buttons */}
{isSubscription && ( )} {isBalanceTopup && ( )} {isDevicesPurchased && ( )} {isTrafficPurchased && ( )}
); if (typeof document !== 'undefined') { return createPortal(modalContent, document.body); } return modalContent; }