From ca227f39759752c3208f5c4dfb622c9ee7d5858b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:04:45 +0300 Subject: [PATCH 1/3] feat(websocket): add global handler for subscription and balance notifications - Extend WSMessage interface with balance, subscription, autopay, account, referral fields - Create WebSocketNotifications component to handle all WS events - Add component to Layout for global event listening - Show toasts and invalidate queries on subscription/balance events - Add translations for all notification types (ru, en, zh, fa) --- src/components/WebSocketNotifications.tsx | 398 ++++++++++++++++++++++ src/components/layout/Layout.tsx | 4 + src/hooks/useWebSocket.ts | 28 +- src/locales/en.json | 52 +++ src/locales/fa.json | 52 +++ src/locales/ru.json | 52 +++ src/locales/zh.json | 52 +++ 7 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 src/components/WebSocketNotifications.tsx diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx new file mode 100644 index 0000000..2284eac --- /dev/null +++ b/src/components/WebSocketNotifications.tsx @@ -0,0 +1,398 @@ +/** + * Global WebSocket notifications handler. + * Listens to all WebSocket events and shows appropriate toasts. + */ + +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useQueryClient } from '@tanstack/react-query'; +import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; +import { useToast } from './Toast'; +import { useAuthStore } from '../store/auth'; +import { useCurrency } from '../hooks/useCurrency'; + +export default function WebSocketNotifications() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const { refreshUser } = useAuthStore(); + const { formatAmount, currencySymbol } = useCurrency(); + + const handleMessage = useCallback( + (message: WSMessage) => { + const { type } = message; + + // Skip ticket events - they are handled by TicketNotificationBell + if (type.startsWith('ticket.')) { + return; + } + + // 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, + }); + // Refresh data + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + refreshUser(); + return; + } + + if (type === 'balance.change') { + const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; + const isPositive = amount >= 0; + showToast({ + type: isPositive ? 'success' : 'info', + title: t('wsNotifications.balance.changeTitle', 'Balance updated'), + message: + message.description || + t( + 'wsNotifications.balance.changeMessage', + 'Your balance has changed by {{amount}} {{currency}}', + { + amount: (isPositive ? '+' : '') + formatAmount(amount), + currency: currencySymbol, + }, + ), + icon: {isPositive ? '💵' : '💸'}, + onClick: () => navigate('/balance'), + duration: 5000, + }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + refreshUser(); + return; + } + + // 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, + }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + refreshUser(); + return; + } + + 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, + }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + refreshUser(); + return; + } + + if (type === 'subscription.expiring') { + showToast({ + type: 'warning', + title: t('wsNotifications.subscription.expiringTitle', 'Subscription expiring soon'), + message: t( + 'wsNotifications.subscription.expiringMessage', + 'Your subscription expires in {{days}} days', + { + days: message.days_left ?? 0, + }, + ), + icon: , + onClick: () => navigate('/subscription'), + duration: 10000, + }); + return; + } + + if (type === 'subscription.expired') { + showToast({ + type: 'error', + title: t('wsNotifications.subscription.expiredTitle', 'Subscription expired'), + message: t( + 'wsNotifications.subscription.expiredMessage', + 'Your subscription has expired. Renew to continue using the service.', + ), + icon: 😢, + onClick: () => navigate('/subscription'), + duration: 10000, + }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + refreshUser(); + return; + } + + if (type === 'subscription.daily_debit') { + const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; + showToast({ + type: 'info', + title: t('wsNotifications.subscription.dailyDebitTitle', 'Daily charge'), + message: t( + 'wsNotifications.subscription.dailyDebitMessage', + 'Daily subscription fee: {{amount}} {{currency}}', + { + amount: formatAmount(amount), + currency: currencySymbol, + }, + ), + icon: 📅, + duration: 5000, + }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + refreshUser(); + return; + } + + if (type === 'subscription.traffic_reset') { + showToast({ + type: 'info', + title: t('wsNotifications.subscription.trafficResetTitle', 'Traffic reset'), + message: t( + 'wsNotifications.subscription.trafficResetMessage', + 'Your traffic limit has been reset', + ), + icon: 🔄, + duration: 5000, + }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + return; + } + + // Autopay events + if (type === 'autopay.success') { + const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; + showToast({ + type: 'success', + title: t('wsNotifications.autopay.successTitle', 'Auto-renewal successful'), + message: t( + 'wsNotifications.autopay.successMessage', + 'Your subscription was auto-renewed for {{amount}} {{currency}}', + { + amount: formatAmount(amount), + currency: currencySymbol, + }, + ), + icon: 🔁, + onClick: () => navigate('/subscription'), + duration: 8000, + }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + refreshUser(); + return; + } + + if (type === 'autopay.failed') { + showToast({ + type: 'error', + title: t('wsNotifications.autopay.failedTitle', 'Auto-renewal failed'), + message: + message.reason || + t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'), + icon: , + onClick: () => navigate('/subscription'), + duration: 10000, + }); + return; + } + + if (type === 'autopay.insufficient_funds') { + const required = message.required_rubles ?? (message.required_kopeks ?? 0) / 100; + const balance = message.balance_rubles ?? (message.balance_kopeks ?? 0) / 100; + showToast({ + type: 'warning', + title: t('wsNotifications.autopay.insufficientTitle', 'Insufficient funds'), + message: t( + 'wsNotifications.autopay.insufficientMessage', + 'Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}', + { + required: formatAmount(required), + balance: formatAmount(balance), + currency: currencySymbol, + }, + ), + icon: 💳, + onClick: () => navigate('/balance'), + duration: 10000, + }); + return; + } + + // Account events + if (type === 'account.banned') { + showToast({ + type: 'error', + title: t('wsNotifications.account.bannedTitle', 'Account blocked'), + message: + message.reason || + t('wsNotifications.account.bannedMessage', 'Your account has been blocked'), + icon: 🚫, + duration: 15000, + }); + refreshUser(); + return; + } + + if (type === 'account.unbanned') { + showToast({ + type: 'success', + title: t('wsNotifications.account.unbannedTitle', 'Account unblocked'), + message: t('wsNotifications.account.unbannedMessage', 'Your account has been unblocked'), + icon: , + duration: 8000, + }); + refreshUser(); + return; + } + + if (type === 'account.warning') { + showToast({ + type: 'warning', + title: t('wsNotifications.account.warningTitle', 'Warning'), + message: + message.message || + t('wsNotifications.account.warningMessage', 'You have received a warning'), + icon: ⚠️, + duration: 10000, + }); + return; + } + + // Referral events + if (type === 'referral.bonus') { + const bonus = message.bonus_rubles ?? (message.bonus_kopeks ?? 0) / 100; + showToast({ + type: 'success', + title: t('wsNotifications.referral.bonusTitle', 'Referral bonus'), + message: message.referral_name + ? t( + 'wsNotifications.referral.bonusWithName', + 'You received {{amount}} {{currency}} bonus from {{name}}!', + { + amount: formatAmount(bonus), + currency: currencySymbol, + name: message.referral_name, + }, + ) + : t( + 'wsNotifications.referral.bonusMessage', + 'You received {{amount}} {{currency}} referral bonus!', + { + amount: formatAmount(bonus), + currency: currencySymbol, + }, + ), + icon: 🎁, + onClick: () => navigate('/referral'), + duration: 8000, + }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['referral-stats'] }); + refreshUser(); + return; + } + + if (type === 'referral.registered') { + showToast({ + type: 'success', + title: t('wsNotifications.referral.registeredTitle', 'New referral'), + message: message.referral_name + ? t( + 'wsNotifications.referral.registeredWithName', + '{{name}} joined using your referral link!', + { name: message.referral_name }, + ) + : t( + 'wsNotifications.referral.registeredMessage', + 'Someone joined using your referral link!', + ), + icon: 👤, + onClick: () => navigate('/referral'), + duration: 6000, + }); + queryClient.invalidateQueries({ queryKey: ['referral-stats'] }); + return; + } + + // Payment received + if (type === 'payment.received') { + const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100; + showToast({ + type: 'success', + title: t('wsNotifications.payment.receivedTitle', 'Payment received'), + message: t( + 'wsNotifications.payment.receivedMessage', + 'Payment of {{amount}} {{currency}} received', + { + amount: formatAmount(amount), + currency: currencySymbol, + }, + ), + icon: 💳, + onClick: () => navigate('/balance'), + duration: 6000, + }); + queryClient.invalidateQueries({ queryKey: ['balance'] }); + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + refreshUser(); + return; + } + }, + [t, showToast, navigate, queryClient, refreshUser, formatAmount, currencySymbol], + ); + + // Connect to WebSocket and handle messages + useWebSocket({ + onMessage: handleMessage, + }); + + // This component doesn't render anything + return null; +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index d8a748d..0cd32e3 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/auth'; import LanguageSwitcher from '../LanguageSwitcher'; import PromoDiscountBadge from '../PromoDiscountBadge'; import TicketNotificationBell from '../TicketNotificationBell'; +import WebSocketNotifications from '../WebSocketNotifications'; import AnimatedBackground from '../AnimatedBackground'; import { contestsApi } from '../../api/contests'; import { pollsApi } from '../../api/polls'; @@ -452,6 +453,9 @@ export default function Layout({ children }: LayoutProps) { return (
+ {/* Global WebSocket notifications handler */} + + {/* Animated Background */} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 4706253..5d52fa8 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -5,11 +5,37 @@ const isDev = import.meta.env.DEV; export interface WSMessage { type: string; + // Ticket events ticket_id?: number; - message?: string; title?: string; + // Common + message?: string; user_id?: number; is_admin?: boolean; + // Balance events + amount_kopeks?: number; + amount_rubles?: number; + new_balance_kopeks?: number; + new_balance_rubles?: number; + description?: string; + // Subscription events + expires_at?: string; + new_expires_at?: string; + tariff_name?: string; + days_left?: number; + // Autopay events + required_kopeks?: number; + required_rubles?: number; + balance_kopeks?: number; + balance_rubles?: number; + reason?: string; + // Account events (ban/warning) + // Referral events + bonus_kopeks?: number; + bonus_rubles?: number; + referral_name?: string; + // Payment events + payment_method?: string; } interface UseWebSocketOptions { diff --git a/src/locales/en.json b/src/locales/en.json index 68f87c9..a645729 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -68,6 +68,58 @@ "newUserReply": "User replied in ticket: {{title}}", "newUserReplyTitle": "User Reply" }, + "wsNotifications": { + "balance": { + "topupTitle": "Balance topped up", + "topupMessage": "Your balance has been topped up by {{amount}} {{currency}}", + "changeTitle": "Balance updated", + "changeMessage": "Your balance has changed by {{amount}} {{currency}}" + }, + "subscription": { + "activatedTitle": "Subscription activated", + "activatedMessage": "Your subscription is now active!", + "activatedWithTariff": "Your subscription \"{{tariff}}\" is now active!", + "renewedTitle": "Subscription renewed", + "renewedMessage": "Your subscription has been renewed!", + "renewedWithAmount": "Your subscription has been renewed for {{amount}} {{currency}}", + "expiringTitle": "Subscription expiring soon", + "expiringMessage": "Your subscription expires in {{days}} days", + "expiredTitle": "Subscription expired", + "expiredMessage": "Your subscription has expired. Renew to continue using the service.", + "dailyDebitTitle": "Daily charge", + "dailyDebitMessage": "Daily subscription fee: {{amount}} {{currency}}", + "trafficResetTitle": "Traffic reset", + "trafficResetMessage": "Your traffic limit has been reset" + }, + "autopay": { + "successTitle": "Auto-renewal successful", + "successMessage": "Your subscription was auto-renewed for {{amount}} {{currency}}", + "failedTitle": "Auto-renewal failed", + "failedMessage": "Failed to auto-renew your subscription", + "insufficientTitle": "Insufficient funds", + "insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}" + }, + "account": { + "bannedTitle": "Account blocked", + "bannedMessage": "Your account has been blocked", + "unbannedTitle": "Account unblocked", + "unbannedMessage": "Your account has been unblocked", + "warningTitle": "Warning", + "warningMessage": "You have received a warning" + }, + "referral": { + "bonusTitle": "Referral bonus", + "bonusMessage": "You received {{amount}} {{currency}} referral bonus!", + "bonusWithName": "You received {{amount}} {{currency}} bonus from {{name}}!", + "registeredTitle": "New referral", + "registeredMessage": "Someone joined using your referral link!", + "registeredWithName": "{{name}} joined using your referral link!" + }, + "payment": { + "receivedTitle": "Payment received", + "receivedMessage": "Payment of {{amount}} {{currency}} received" + } + }, "auth": { "login": "Login", "register": "Register", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3497226..760f0d9 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -62,6 +62,58 @@ "newUserReply": "کاربر در تیکت پاسخ داد: {{title}}", "newUserReplyTitle": "پاسخ کاربر" }, + "wsNotifications": { + "balance": { + "topupTitle": "موجودی شارژ شد", + "topupMessage": "موجودی شما {{amount}} {{currency}} شارژ شد", + "changeTitle": "موجودی به‌روز شد", + "changeMessage": "موجودی شما {{amount}} {{currency}} تغییر کرد" + }, + "subscription": { + "activatedTitle": "اشتراک فعال شد", + "activatedMessage": "اشتراک شما اکنون فعال است!", + "activatedWithTariff": "اشتراک «{{tariff}}» شما اکنون فعال است!", + "renewedTitle": "اشتراک تمدید شد", + "renewedMessage": "اشتراک شما تمدید شد!", + "renewedWithAmount": "اشتراک شما با {{amount}} {{currency}} تمدید شد", + "expiringTitle": "اشتراک به زودی منقضی می‌شود", + "expiringMessage": "اشتراک شما {{days}} روز دیگر منقضی می‌شود", + "expiredTitle": "اشتراک منقضی شد", + "expiredMessage": "اشتراک شما منقضی شده است. برای ادامه استفاده تمدید کنید.", + "dailyDebitTitle": "کسر روزانه", + "dailyDebitMessage": "هزینه روزانه اشتراک: {{amount}} {{currency}}", + "trafficResetTitle": "ترافیک بازنشانی شد", + "trafficResetMessage": "محدودیت ترافیک شما بازنشانی شد" + }, + "autopay": { + "successTitle": "تمدید خودکار موفق", + "successMessage": "اشتراک شما به صورت خودکار با {{amount}} {{currency}} تمدید شد", + "failedTitle": "خطا در تمدید خودکار", + "failedMessage": "تمدید خودکار اشتراک شما ناموفق بود", + "insufficientTitle": "موجودی ناکافی", + "insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}" + }, + "account": { + "bannedTitle": "حساب مسدود شد", + "bannedMessage": "حساب شما مسدود شده است", + "unbannedTitle": "حساب رفع مسدودی شد", + "unbannedMessage": "حساب شما رفع مسدودی شد", + "warningTitle": "هشدار", + "warningMessage": "شما یک هشدار دریافت کردید" + }, + "referral": { + "bonusTitle": "پاداش معرفی", + "bonusMessage": "شما {{amount}} {{currency}} پاداش معرفی دریافت کردید!", + "bonusWithName": "شما از {{name}} پاداش {{amount}} {{currency}} دریافت کردید!", + "registeredTitle": "معرفی جدید", + "registeredMessage": "کسی با لینک معرفی شما ثبت نام کرد!", + "registeredWithName": "{{name}} با لینک معرفی شما ثبت نام کرد!" + }, + "payment": { + "receivedTitle": "پرداخت دریافت شد", + "receivedMessage": "پرداخت {{amount}} {{currency}} دریافت شد" + } + }, "auth": { "login": "ورود", "register": "ثبت نام", diff --git a/src/locales/ru.json b/src/locales/ru.json index 717b1e2..944f581 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -71,6 +71,58 @@ "newUserReply": "Ответ пользователя в тикете: {{title}}", "newUserReplyTitle": "Ответ пользователя" }, + "wsNotifications": { + "balance": { + "topupTitle": "Баланс пополнен", + "topupMessage": "Ваш баланс пополнен на {{amount}} {{currency}}", + "changeTitle": "Баланс изменён", + "changeMessage": "Ваш баланс изменился на {{amount}} {{currency}}" + }, + "subscription": { + "activatedTitle": "Подписка активирована", + "activatedMessage": "Ваша подписка теперь активна!", + "activatedWithTariff": "Ваша подписка «{{tariff}}» теперь активна!", + "renewedTitle": "Подписка продлена", + "renewedMessage": "Ваша подписка была продлена!", + "renewedWithAmount": "Ваша подписка продлена за {{amount}} {{currency}}", + "expiringTitle": "Подписка скоро истекает", + "expiringMessage": "Ваша подписка истекает через {{days}} дн.", + "expiredTitle": "Подписка истекла", + "expiredMessage": "Ваша подписка истекла. Продлите для продолжения использования.", + "dailyDebitTitle": "Ежедневное списание", + "dailyDebitMessage": "Списание за подписку: {{amount}} {{currency}}", + "trafficResetTitle": "Трафик сброшен", + "trafficResetMessage": "Ваш лимит трафика был сброшен" + }, + "autopay": { + "successTitle": "Автопродление успешно", + "successMessage": "Подписка автоматически продлена за {{amount}} {{currency}}", + "failedTitle": "Ошибка автопродления", + "failedMessage": "Не удалось автоматически продлить подписку", + "insufficientTitle": "Недостаточно средств", + "insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}" + }, + "account": { + "bannedTitle": "Аккаунт заблокирован", + "bannedMessage": "Ваш аккаунт был заблокирован", + "unbannedTitle": "Аккаунт разблокирован", + "unbannedMessage": "Ваш аккаунт был разблокирован", + "warningTitle": "Предупреждение", + "warningMessage": "Вы получили предупреждение" + }, + "referral": { + "bonusTitle": "Реферальный бонус", + "bonusMessage": "Вы получили реферальный бонус {{amount}} {{currency}}!", + "bonusWithName": "Вы получили бонус {{amount}} {{currency}} от {{name}}!", + "registeredTitle": "Новый реферал", + "registeredMessage": "Кто-то зарегистрировался по вашей ссылке!", + "registeredWithName": "{{name}} зарегистрировался по вашей ссылке!" + }, + "payment": { + "receivedTitle": "Платёж получен", + "receivedMessage": "Получен платёж на сумму {{amount}} {{currency}}" + } + }, "auth": { "login": "Вход", "register": "Регистрация", diff --git a/src/locales/zh.json b/src/locales/zh.json index 88081b3..626b522 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -62,6 +62,58 @@ "newUserReply": "用户回复了工单:{{title}}", "newUserReplyTitle": "用户回复" }, + "wsNotifications": { + "balance": { + "topupTitle": "余额已充值", + "topupMessage": "您的余额已充值 {{amount}} {{currency}}", + "changeTitle": "余额已更新", + "changeMessage": "您的余额已变动 {{amount}} {{currency}}" + }, + "subscription": { + "activatedTitle": "订阅已激活", + "activatedMessage": "您的订阅现已激活!", + "activatedWithTariff": "您的订阅「{{tariff}}」现已激活!", + "renewedTitle": "订阅已续期", + "renewedMessage": "您的订阅已续期!", + "renewedWithAmount": "您的订阅已续期,费用 {{amount}} {{currency}}", + "expiringTitle": "订阅即将到期", + "expiringMessage": "您的订阅将在 {{days}} 天后到期", + "expiredTitle": "订阅已过期", + "expiredMessage": "您的订阅已过期。请续期以继续使用服务。", + "dailyDebitTitle": "每日扣费", + "dailyDebitMessage": "每日订阅费:{{amount}} {{currency}}", + "trafficResetTitle": "流量已重置", + "trafficResetMessage": "您的流量限制已重置" + }, + "autopay": { + "successTitle": "自动续期成功", + "successMessage": "您的订阅已自动续期,费用 {{amount}} {{currency}}", + "failedTitle": "自动续期失败", + "failedMessage": "自动续期订阅失败", + "insufficientTitle": "余额不足", + "insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}" + }, + "account": { + "bannedTitle": "账户已封禁", + "bannedMessage": "您的账户已被封禁", + "unbannedTitle": "账户已解封", + "unbannedMessage": "您的账户已解封", + "warningTitle": "警告", + "warningMessage": "您收到了一条警告" + }, + "referral": { + "bonusTitle": "推荐奖励", + "bonusMessage": "您获得了 {{amount}} {{currency}} 推荐奖励!", + "bonusWithName": "您从 {{name}} 获得了 {{amount}} {{currency}} 奖励!", + "registeredTitle": "新推荐用户", + "registeredMessage": "有人通过您的推荐链接注册了!", + "registeredWithName": "{{name}} 通过您的推荐链接注册了!" + }, + "payment": { + "receivedTitle": "已收到付款", + "receivedMessage": "收到付款 {{amount}} {{currency}}" + } + }, "auth": { "login": "登录", "register": "注册", From 175b5608b2ea5f8defd9d74ecd7d89588f70d713 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:39:14 +0300 Subject: [PATCH 2/3] 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 }), +})); From 0be1ef034054d01b76e262515f20761910323a74 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:54:29 +0300 Subject: [PATCH 3/3] feat: auto-close modals when success notification appears - Add closeOthersSignal to success notification store - Create useCloseOnSuccessNotification hook for modals to subscribe - Update TopUpModal to auto-close on success events - Update InsufficientBalancePrompt to auto-close on success events - Update Subscription page to close all modals/forms on success events This ensures that when a WebSocket event indicates successful payment or subscription purchase, all related modals close automatically, providing clear feedback to users. --- src/components/InsufficientBalancePrompt.tsx | 10 ++++++- src/components/TopUpModal.tsx | 4 +++ src/pages/Subscription.tsx | 18 ++++++++++++- src/store/successNotification.ts | 28 +++++++++++++++++++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index a2802af..1ef404b 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; +import { useCloseOnSuccessNotification } from '../store/successNotification'; import TopUpModal from './TopUpModal'; import type { PaymentMethod } from '../types'; @@ -28,6 +29,13 @@ export default function InsufficientBalancePrompt({ const [showMethodSelect, setShowMethodSelect] = useState(false); const [selectedMethod, setSelectedMethod] = useState(null); + // Auto-close modals when success notification appears + const handleCloseAll = useCallback(() => { + setShowMethodSelect(false); + setSelectedMethod(null); + }, []); + useCloseOnSuccessNotification(handleCloseAll); + const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: balanceApi.getPaymentMethods, diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 0ebcd3d..5f2975d 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -6,6 +6,7 @@ import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; +import { useCloseOnSuccessNotification } from '../store/successNotification'; import type { PaymentMethod } from '../types'; import BentoCard from './ui/BentoCard'; @@ -144,6 +145,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top onClose(); }, [onClose]); + // Auto-close when success notification appears (e.g., balance topped up via WebSocket) + useCloseOnSuccessNotification(handleClose); + // Keyboard: Escape to close (PC) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index d58f85d..df35be0 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; @@ -15,6 +15,7 @@ import type { import ConnectionModal from '../components/ConnectionModal'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; +import { useCloseOnSuccessNotification } from '../store/successNotification'; import i18n from '../i18n'; // Helper to extract error message from axios/api errors @@ -279,6 +280,21 @@ export default function Subscription() { // Tariff switch preview const [switchTariffId, setSwitchTariffId] = useState(null); + + // Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket) + const handleCloseAllModals = useCallback(() => { + setShowConnectionModal(false); + setShowPurchaseForm(false); + setShowTariffPurchase(false); + setShowDeviceTopup(false); + setShowTrafficTopup(false); + setShowServerManagement(false); + setSwitchTariffId(null); + setSelectedTariff(null); + setSelectedTariffPeriod(null); + }, []); + useCloseOnSuccessNotification(handleCloseAllModals); + const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ queryKey: ['tariff-switch-preview', switchTariffId], queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!), diff --git a/src/store/successNotification.ts b/src/store/successNotification.ts index 72a92ec..eb04ab0 100644 --- a/src/store/successNotification.ts +++ b/src/store/successNotification.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { create } from 'zustand'; export type SuccessNotificationType = @@ -25,6 +26,8 @@ export interface SuccessNotificationData { interface SuccessNotificationState { isOpen: boolean; data: SuccessNotificationData | null; + /** Signal that increments when other modals should close */ + closeOthersSignal: number; show: (data: SuccessNotificationData) => void; hide: () => void; @@ -33,7 +36,30 @@ interface SuccessNotificationState { export const useSuccessNotification = create((set) => ({ isOpen: false, data: null, + closeOthersSignal: 0, - show: (data) => set({ isOpen: true, data }), + show: (data) => + set((state) => ({ + isOpen: true, + data, + // Increment signal to tell other modals to close + closeOthersSignal: state.closeOthersSignal + 1, + })), hide: () => set({ isOpen: false, data: null }), })); + +/** + * Hook that calls onClose when a success notification appears. + * Use this in modals that should auto-close on success events. + */ +export function useCloseOnSuccessNotification(onClose: () => void) { + const closeOthersSignal = useSuccessNotification((state) => state.closeOthersSignal); + + useEffect(() => { + // Skip the initial render (signal = 0) + if (closeOthersSignal > 0) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [closeOthersSignal]); +}