From 175b5608b2ea5f8defd9d74ecd7d89588f70d713 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:39:14 +0300 Subject: [PATCH] feat: add success notification modal for balance and subscription events - Add zustand store for global success notifications - Create SuccessNotificationModal component with prominent UI - Update WebSocketNotifications to show modal for important events: - balance.topup shows balance topped up modal - subscription.activated shows activation modal - subscription.renewed shows renewal modal - Add translations for all locales (ru, en, zh, fa) --- src/components/SuccessNotificationModal.tsx | 284 ++++++++++++++++++++ src/components/WebSocketNotifications.tsx | 82 ++---- src/components/layout/Layout.tsx | 4 + src/locales/en.json | 21 ++ src/locales/fa.json | 21 ++ src/locales/ru.json | 21 ++ src/locales/zh.json | 21 ++ src/store/successNotification.ts | 39 +++ 8 files changed, 439 insertions(+), 54 deletions(-) create mode 100644 src/components/SuccessNotificationModal.tsx create mode 100644 src/store/successNotification.ts diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx new file mode 100644 index 0000000..8bd9ced --- /dev/null +++ b/src/components/SuccessNotificationModal.tsx @@ -0,0 +1,284 @@ +/** + * 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-dom'; +import { useSuccessNotification } from '../store/successNotification'; +import { useCurrency } from '../hooks/useCurrency'; +import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; + +// Icons +const CheckCircleIcon = () => ( + + + +); + +const WalletIcon = () => ( + + + +); + +const RocketIcon = () => ( + + + +); + +const CloseIcon = () => ( + + + +); + +export default function SuccessNotificationModal() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { isOpen, data, hide } = useSuccessNotification(); + const { formatAmount, currencySymbol } = useCurrency(); + const { webApp, safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp(); + + const safeBottom = isTelegramWebApp + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; + + const handleClose = useCallback(() => { + hide(); + }, [hide]); + + // 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]); + + // Telegram back button + useEffect(() => { + if (!isOpen || !webApp?.BackButton) return; + + webApp.BackButton.show(); + webApp.BackButton.onClick(handleClose); + + return () => { + webApp.BackButton.offClick(handleClose); + webApp.BackButton.hide(); + }; + }, [isOpen, webApp, handleClose]); + + // 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'; + + // 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-emerald-600'; + + if (!title) { + if (isBalanceTopup) { + title = t('successNotification.balanceTopup.title', 'Balance topped up!'); + icon = ; + gradientClass = 'from-success-500 to-emerald-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'; + } + } + + const handleGoToSubscription = () => { + hide(); + navigate('/subscription'); + }; + + const handleGoToBalance = () => { + hide(); + navigate('/balance'); + }; + + const modalContent = ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
e.stopPropagation()} + > + {/* Close button */} + + + {/* Success header with animation */} +
+
{icon}
+

{title}

+ {message &&

{message}

} +
+ + {/* Details */} +
+ {/* Amount */} + {formattedAmount && ( +
+ + {isBalanceTopup + ? t('successNotification.amount', 'Amount') + : t('successNotification.price', 'Price')} + + +{formattedAmount} +
+ )} + + {/* 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 && ( + + )} + + +
+
+
+
+ ); + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body); + } + return modalContent; +} diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index 2284eac..5c1034c 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -1,6 +1,6 @@ /** * Global WebSocket notifications handler. - * Listens to all WebSocket events and shows appropriate toasts. + * Listens to all WebSocket events and shows appropriate toasts or modals. */ import { useCallback } from 'react'; @@ -11,6 +11,7 @@ import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useToast } from './Toast'; import { useAuthStore } from '../store/auth'; import { useCurrency } from '../hooks/useCurrency'; +import { useSuccessNotification } from '../store/successNotification'; export default function WebSocketNotifications() { const { t } = useTranslation(); @@ -19,6 +20,7 @@ export default function WebSocketNotifications() { const { showToast } = useToast(); const { refreshUser } = useAuthStore(); const { formatAmount, currencySymbol } = useCurrency(); + const { show: showSuccessModal } = useSuccessNotification(); const handleMessage = useCallback( (message: WSMessage) => { @@ -31,21 +33,11 @@ export default function WebSocketNotifications() { // Balance events if (type === 'balance.topup') { - const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; - showToast({ - type: 'success', - title: t('wsNotifications.balance.topupTitle', 'Balance topped up'), - message: t( - 'wsNotifications.balance.topupMessage', - 'Your balance has been topped up by {{amount}} {{currency}}', - { - amount: formatAmount(amount), - currency: currencySymbol, - }, - ), - icon: 💰, - onClick: () => navigate('/balance'), - duration: 6000, + // Show prominent success modal for balance top-up + showSuccessModal({ + type: 'balance_topup', + amountKopeks: message.amount_kopeks, + newBalanceKopeks: message.new_balance_kopeks, }); // Refresh data queryClient.invalidateQueries({ queryKey: ['balance'] }); @@ -82,22 +74,11 @@ export default function WebSocketNotifications() { // Subscription events if (type === 'subscription.activated') { - showToast({ - type: 'success', - title: t('wsNotifications.subscription.activatedTitle', 'Subscription activated'), - message: message.tariff_name - ? t( - 'wsNotifications.subscription.activatedWithTariff', - 'Your subscription "{{tariff}}" is now active!', - { tariff: message.tariff_name }, - ) - : t( - 'wsNotifications.subscription.activatedMessage', - 'Your subscription is now active!', - ), - icon: 🎉, - onClick: () => navigate('/subscription'), - duration: 8000, + // Show prominent success modal for subscription activation + showSuccessModal({ + type: 'subscription_activated', + expiresAt: message.expires_at, + tariffName: message.tariff_name, }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); @@ -106,27 +87,11 @@ export default function WebSocketNotifications() { } if (type === 'subscription.renewed') { - const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; - showToast({ - type: 'success', - title: t('wsNotifications.subscription.renewedTitle', 'Subscription renewed'), - message: - amount > 0 - ? t( - 'wsNotifications.subscription.renewedWithAmount', - 'Your subscription has been renewed for {{amount}} {{currency}}', - { - amount: formatAmount(amount), - currency: currencySymbol, - }, - ) - : t( - 'wsNotifications.subscription.renewedMessage', - 'Your subscription has been renewed!', - ), - icon: , - onClick: () => navigate('/subscription'), - duration: 8000, + // Show prominent success modal for subscription renewal + showSuccessModal({ + type: 'subscription_renewed', + amountKopeks: message.amount_kopeks, + expiresAt: message.new_expires_at, }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); @@ -385,7 +350,16 @@ export default function WebSocketNotifications() { return; } }, - [t, showToast, navigate, queryClient, refreshUser, formatAmount, currencySymbol], + [ + t, + showToast, + showSuccessModal, + navigate, + queryClient, + refreshUser, + formatAmount, + currencySymbol, + ], ); // Connect to WebSocket and handle messages diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 0cd32e3..59d2aee 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -7,6 +7,7 @@ import LanguageSwitcher from '../LanguageSwitcher'; import PromoDiscountBadge from '../PromoDiscountBadge'; import TicketNotificationBell from '../TicketNotificationBell'; import WebSocketNotifications from '../WebSocketNotifications'; +import SuccessNotificationModal from '../SuccessNotificationModal'; import AnimatedBackground from '../AnimatedBackground'; import { contestsApi } from '../../api/contests'; import { pollsApi } from '../../api/polls'; @@ -456,6 +457,9 @@ export default function Layout({ children }: LayoutProps) { {/* Global WebSocket notifications handler */} + {/* Global success notification modal */} + + {/* Animated Background */} diff --git a/src/locales/en.json b/src/locales/en.json index a645729..0b8ccbe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -120,6 +120,27 @@ "receivedMessage": "Payment of {{amount}} {{currency}} received" } }, + "successNotification": { + "balanceTopup": { + "title": "Balance topped up!" + }, + "subscriptionActivated": { + "title": "Subscription activated!" + }, + "subscriptionRenewed": { + "title": "Subscription renewed!" + }, + "subscriptionPurchased": { + "title": "Subscription purchased!" + }, + "amount": "Amount", + "price": "Price", + "newBalance": "New balance", + "tariff": "Tariff", + "validUntil": "Valid until", + "goToSubscription": "Go to Subscription", + "goToBalance": "Go to Balance" + }, "auth": { "login": "Login", "register": "Register", diff --git a/src/locales/fa.json b/src/locales/fa.json index 760f0d9..e6ec9c6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -114,6 +114,27 @@ "receivedMessage": "پرداخت {{amount}} {{currency}} دریافت شد" } }, + "successNotification": { + "balanceTopup": { + "title": "موجودی شارژ شد!" + }, + "subscriptionActivated": { + "title": "اشتراک فعال شد!" + }, + "subscriptionRenewed": { + "title": "اشتراک تمدید شد!" + }, + "subscriptionPurchased": { + "title": "اشتراک خریداری شد!" + }, + "amount": "مبلغ", + "price": "قیمت", + "newBalance": "موجودی جدید", + "tariff": "تعرفه", + "validUntil": "معتبر تا", + "goToSubscription": "رفتن به اشتراک", + "goToBalance": "رفتن به موجودی" + }, "auth": { "login": "ورود", "register": "ثبت نام", diff --git a/src/locales/ru.json b/src/locales/ru.json index 944f581..07ccdea 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -123,6 +123,27 @@ "receivedMessage": "Получен платёж на сумму {{amount}} {{currency}}" } }, + "successNotification": { + "balanceTopup": { + "title": "Баланс пополнен!" + }, + "subscriptionActivated": { + "title": "Подписка активирована!" + }, + "subscriptionRenewed": { + "title": "Подписка продлена!" + }, + "subscriptionPurchased": { + "title": "Подписка оплачена!" + }, + "amount": "Сумма", + "price": "Стоимость", + "newBalance": "Новый баланс", + "tariff": "Тариф", + "validUntil": "Действует до", + "goToSubscription": "Перейти к подписке", + "goToBalance": "Перейти к балансу" + }, "auth": { "login": "Вход", "register": "Регистрация", diff --git a/src/locales/zh.json b/src/locales/zh.json index 626b522..16c582f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -114,6 +114,27 @@ "receivedMessage": "收到付款 {{amount}} {{currency}}" } }, + "successNotification": { + "balanceTopup": { + "title": "余额已充值!" + }, + "subscriptionActivated": { + "title": "订阅已激活!" + }, + "subscriptionRenewed": { + "title": "订阅已续期!" + }, + "subscriptionPurchased": { + "title": "订阅已购买!" + }, + "amount": "金额", + "price": "价格", + "newBalance": "新余额", + "tariff": "套餐", + "validUntil": "有效期至", + "goToSubscription": "查看订阅", + "goToBalance": "查看余额" + }, "auth": { "login": "登录", "register": "注册", diff --git a/src/store/successNotification.ts b/src/store/successNotification.ts new file mode 100644 index 0000000..72a92ec --- /dev/null +++ b/src/store/successNotification.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; + +export type SuccessNotificationType = + | 'balance_topup' + | 'subscription_activated' + | 'subscription_renewed' + | 'subscription_purchased'; + +export interface SuccessNotificationData { + type: SuccessNotificationType; + /** Amount in kopeks (for balance or subscription price) */ + amountKopeks?: number; + /** New balance in kopeks */ + newBalanceKopeks?: number; + /** Subscription expiry date ISO string */ + expiresAt?: string; + /** Tariff name */ + tariffName?: string; + /** Custom title override */ + title?: string; + /** Custom message override */ + message?: string; +} + +interface SuccessNotificationState { + isOpen: boolean; + data: SuccessNotificationData | null; + + show: (data: SuccessNotificationData) => void; + hide: () => void; +} + +export const useSuccessNotification = create((set) => ({ + isOpen: false, + data: null, + + show: (data) => set({ isOpen: true, data }), + hide: () => set({ isOpen: false, data: null }), +}));