diff --git a/README.md b/README.md index a9b2a1f..9803329 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ CABINET_PORT=3020 docker compose up -d --build ``` -Приложение будет доступно на `http://localhost:3000` +Приложение будет доступно на `http://localhost:3020` ## Настройка backend @@ -93,7 +93,7 @@ CABINET_ENABLED=true CABINET_JWT_SECRET=your_random_secret_key_here # Разрешенные origins для CORS -CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com +CABINET_ALLOWED_ORIGINS=http://localhost:3020,https://cabinet.yourdomain.com ``` После изменений перезапустите бота. @@ -134,6 +134,21 @@ cabinet.yourdomain.com { uri strip_prefix /api reverse_proxy backend_bot:8080 } + @websockets { + header_regexp Connection *Upgrade* + header Upgrade websocket + } + + # WebSocket соединения + handle /cabinet/ws { + uri strip_prefix /api + reverse_proxy backend_bot:8080 { + transport http { + read_timeout 0 + write_timeout 0 + } + } + } # Статические файлы handle { @@ -141,6 +156,7 @@ cabinet.yourdomain.com { file_server } } + ``` docker-compose.yml для Caddy: @@ -283,7 +299,7 @@ services: restart: unless-stopped # Можно открыть порт для прямого доступа (для тестирования) # ports: - # - "3000:80" + # - "3020:80" networks: - web @@ -309,7 +325,7 @@ networks: | Переменная | Описание | По умолчанию | | -------------- | --------------- | ------------ | -| `CABINET_PORT` | Порт контейнера | `3000` | +| `CABINET_PORT` | Порт контейнера | `3020` | ## Структура проекта 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/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/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/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx new file mode 100644 index 0000000..5c1034c --- /dev/null +++ b/src/components/WebSocketNotifications.tsx @@ -0,0 +1,372 @@ +/** + * Global WebSocket notifications handler. + * Listens to all WebSocket events and shows appropriate toasts or modals. + */ + +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'; +import { useSuccessNotification } from '../store/successNotification'; + +export default function WebSocketNotifications() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const { refreshUser } = useAuthStore(); + const { formatAmount, currencySymbol } = useCurrency(); + const { show: showSuccessModal } = useSuccessNotification(); + + 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') { + // 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'] }); + 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') { + // 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'] }); + refreshUser(); + return; + } + + if (type === 'subscription.renewed') { + // 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'] }); + 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, + showSuccessModal, + 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..59d2aee 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -6,6 +6,8 @@ import { useAuthStore } from '../../store/auth'; 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'; @@ -452,6 +454,12 @@ export default function Layout({ children }: LayoutProps) { return (
+ {/* Global WebSocket notifications handler */} + + + {/* Global success notification modal */} + + {/* 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..0b8ccbe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -68,6 +68,79 @@ "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" + } + }, + "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 3497226..e6ec9c6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -62,6 +62,79 @@ "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}} دریافت شد" + } + }, + "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 717b1e2..07ccdea 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -71,6 +71,79 @@ "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}}" + } + }, + "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 88081b3..16c582f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -62,6 +62,79 @@ "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}}" + } + }, + "successNotification": { + "balanceTopup": { + "title": "余额已充值!" + }, + "subscriptionActivated": { + "title": "订阅已激活!" + }, + "subscriptionRenewed": { + "title": "订阅已续期!" + }, + "subscriptionPurchased": { + "title": "订阅已购买!" + }, + "amount": "金额", + "price": "价格", + "newBalance": "新余额", + "tariff": "套餐", + "validUntil": "有效期至", + "goToSubscription": "查看订阅", + "goToBalance": "查看余额" + }, "auth": { "login": "登录", "register": "注册", 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 new file mode 100644 index 0000000..eb04ab0 --- /dev/null +++ b/src/store/successNotification.ts @@ -0,0 +1,65 @@ +import { useEffect } from 'react'; +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; + /** Signal that increments when other modals should close */ + closeOthersSignal: number; + + show: (data: SuccessNotificationData) => void; + hide: () => void; +} + +export const useSuccessNotification = create((set) => ({ + isOpen: false, + data: null, + closeOthersSignal: 0, + + 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]); +}