mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Merge pull request #120 from BEDOLAGA-DEV/feat/websocket-subscription-balance-notifications
feat/websocket subscription balance notifications
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { balanceApi } from '../api/balance';
|
import { balanceApi } from '../api/balance';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
import TopUpModal from './TopUpModal';
|
import TopUpModal from './TopUpModal';
|
||||||
import type { PaymentMethod } from '../types';
|
import type { PaymentMethod } from '../types';
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ export default function InsufficientBalancePrompt({
|
|||||||
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||||
|
|
||||||
|
// Auto-close modals when success notification appears
|
||||||
|
const handleCloseAll = useCallback(() => {
|
||||||
|
setShowMethodSelect(false);
|
||||||
|
setSelectedMethod(null);
|
||||||
|
}, []);
|
||||||
|
useCloseOnSuccessNotification(handleCloseAll);
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
const { data: paymentMethods } = useQuery({
|
||||||
queryKey: ['payment-methods'],
|
queryKey: ['payment-methods'],
|
||||||
queryFn: balanceApi.getPaymentMethods,
|
queryFn: balanceApi.getPaymentMethods,
|
||||||
|
|||||||
284
src/components/SuccessNotificationModal.tsx
Normal file
284
src/components/SuccessNotificationModal.tsx
Normal file
@@ -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 = () => (
|
||||||
|
<svg
|
||||||
|
className="h-16 w-16"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const WalletIcon = () => (
|
||||||
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RocketIcon = () => (
|
||||||
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CloseIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = <CheckCircleIcon />;
|
||||||
|
let gradientClass = 'from-success-500 to-emerald-600';
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
if (isBalanceTopup) {
|
||||||
|
title = t('successNotification.balanceTopup.title', 'Balance topped up!');
|
||||||
|
icon = <WalletIcon />;
|
||||||
|
gradientClass = 'from-success-500 to-emerald-600';
|
||||||
|
} else if (data.type === 'subscription_activated') {
|
||||||
|
title = t('successNotification.subscriptionActivated.title', 'Subscription activated!');
|
||||||
|
icon = <RocketIcon />;
|
||||||
|
gradientClass = 'from-accent-500 to-purple-600';
|
||||||
|
} else if (data.type === 'subscription_renewed') {
|
||||||
|
title = t('successNotification.subscriptionRenewed.title', 'Subscription renewed!');
|
||||||
|
icon = <RocketIcon />;
|
||||||
|
gradientClass = 'from-accent-500 to-purple-600';
|
||||||
|
} else if (data.type === 'subscription_purchased') {
|
||||||
|
title = t('successNotification.subscriptionPurchased.title', 'Subscription purchased!');
|
||||||
|
icon = <RocketIcon />;
|
||||||
|
gradientClass = 'from-accent-500 to-purple-600';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoToSubscription = () => {
|
||||||
|
hide();
|
||||||
|
navigate('/subscription');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToBalance = () => {
|
||||||
|
hide();
|
||||||
|
navigate('/balance');
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||||
|
style={{
|
||||||
|
marginBottom: safeBottom ? `${safeBottom}px` : undefined,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-3 top-3 z-10 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Success header with animation */}
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center bg-gradient-to-br ${gradientClass} px-6 pb-8 pt-10`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 animate-bounce text-white">{icon}</div>
|
||||||
|
<h2 className="text-center text-2xl font-bold text-white">{title}</h2>
|
||||||
|
{message && <p className="mt-2 text-center text-white/80">{message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
{/* Amount */}
|
||||||
|
{formattedAmount && (
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{isBalanceTopup
|
||||||
|
? t('successNotification.amount', 'Amount')
|
||||||
|
: t('successNotification.price', 'Price')}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-success-400">+{formattedAmount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New balance (for top-up) */}
|
||||||
|
{isBalanceTopup && formattedBalance && (
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('successNotification.newBalance', 'New balance')}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-dark-100">{formattedBalance}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tariff name */}
|
||||||
|
{data.tariffName && (
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3">
|
||||||
|
<span className="text-dark-400">{t('successNotification.tariff', 'Tariff')}</span>
|
||||||
|
<span className="font-semibold text-dark-100">{data.tariffName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expiry date */}
|
||||||
|
{formattedExpiry && (
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-dark-800/50 px-4 py-3">
|
||||||
|
<span className="text-dark-400">
|
||||||
|
{t('successNotification.validUntil', 'Valid until')}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-dark-100">{formattedExpiry}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
{isSubscription && (
|
||||||
|
<button
|
||||||
|
onClick={handleGoToSubscription}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-accent-500 to-accent-600 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-all hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700"
|
||||||
|
>
|
||||||
|
<RocketIcon />
|
||||||
|
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBalanceTopup && (
|
||||||
|
<button
|
||||||
|
onClick={handleGoToBalance}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-success-500 to-emerald-600 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-emerald-500 active:from-success-600 active:to-emerald-700"
|
||||||
|
>
|
||||||
|
<WalletIcon />
|
||||||
|
<span>{t('successNotification.goToBalance', 'Go to Balance')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-full rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100"
|
||||||
|
>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { balanceApi } from '../api/balance';
|
|||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||||
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
import type { PaymentMethod } from '../types';
|
import type { PaymentMethod } from '../types';
|
||||||
import BentoCard from './ui/BentoCard';
|
import BentoCard from './ui/BentoCard';
|
||||||
|
|
||||||
@@ -144,6 +145,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Auto-close when success notification appears (e.g., balance topped up via WebSocket)
|
||||||
|
useCloseOnSuccessNotification(handleClose);
|
||||||
|
|
||||||
// Keyboard: Escape to close (PC)
|
// Keyboard: Escape to close (PC)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
372
src/components/WebSocketNotifications.tsx
Normal file
372
src/components/WebSocketNotifications.tsx
Normal file
@@ -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: <span className="text-lg">{isPositive ? '💵' : '💸'}</span>,
|
||||||
|
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: <span className="text-lg">⏰</span>,
|
||||||
|
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: <span className="text-lg">😢</span>,
|
||||||
|
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: <span className="text-lg">📅</span>,
|
||||||
|
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: <span className="text-lg">🔄</span>,
|
||||||
|
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: <span className="text-lg">🔁</span>,
|
||||||
|
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: <span className="text-lg">❌</span>,
|
||||||
|
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: <span className="text-lg">💳</span>,
|
||||||
|
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: <span className="text-lg">🚫</span>,
|
||||||
|
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: <span className="text-lg">✅</span>,
|
||||||
|
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: <span className="text-lg">⚠️</span>,
|
||||||
|
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: <span className="text-lg">🎁</span>,
|
||||||
|
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: <span className="text-lg">👤</span>,
|
||||||
|
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: <span className="text-lg">💳</span>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { useAuthStore } from '../../store/auth';
|
|||||||
import LanguageSwitcher from '../LanguageSwitcher';
|
import LanguageSwitcher from '../LanguageSwitcher';
|
||||||
import PromoDiscountBadge from '../PromoDiscountBadge';
|
import PromoDiscountBadge from '../PromoDiscountBadge';
|
||||||
import TicketNotificationBell from '../TicketNotificationBell';
|
import TicketNotificationBell from '../TicketNotificationBell';
|
||||||
|
import WebSocketNotifications from '../WebSocketNotifications';
|
||||||
|
import SuccessNotificationModal from '../SuccessNotificationModal';
|
||||||
import AnimatedBackground from '../AnimatedBackground';
|
import AnimatedBackground from '../AnimatedBackground';
|
||||||
import { contestsApi } from '../../api/contests';
|
import { contestsApi } from '../../api/contests';
|
||||||
import { pollsApi } from '../../api/polls';
|
import { pollsApi } from '../../api/polls';
|
||||||
@@ -452,6 +454,12 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
|
{/* Global WebSocket notifications handler */}
|
||||||
|
<WebSocketNotifications />
|
||||||
|
|
||||||
|
{/* Global success notification modal */}
|
||||||
|
<SuccessNotificationModal />
|
||||||
|
|
||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,37 @@ const isDev = import.meta.env.DEV;
|
|||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
|
// Ticket events
|
||||||
ticket_id?: number;
|
ticket_id?: number;
|
||||||
message?: string;
|
|
||||||
title?: string;
|
title?: string;
|
||||||
|
// Common
|
||||||
|
message?: string;
|
||||||
user_id?: number;
|
user_id?: number;
|
||||||
is_admin?: boolean;
|
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 {
|
interface UseWebSocketOptions {
|
||||||
|
|||||||
@@ -68,6 +68,79 @@
|
|||||||
"newUserReply": "User replied in ticket: {{title}}",
|
"newUserReply": "User replied in ticket: {{title}}",
|
||||||
"newUserReplyTitle": "User Reply"
|
"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": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
|
|||||||
@@ -62,6 +62,79 @@
|
|||||||
"newUserReply": "کاربر در تیکت پاسخ داد: {{title}}",
|
"newUserReply": "کاربر در تیکت پاسخ داد: {{title}}",
|
||||||
"newUserReplyTitle": "پاسخ کاربر"
|
"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": {
|
"auth": {
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
"register": "ثبت نام",
|
"register": "ثبت نام",
|
||||||
|
|||||||
@@ -71,6 +71,79 @@
|
|||||||
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
"newUserReply": "Ответ пользователя в тикете: {{title}}",
|
||||||
"newUserReplyTitle": "Ответ пользователя"
|
"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": {
|
"auth": {
|
||||||
"login": "Вход",
|
"login": "Вход",
|
||||||
"register": "Регистрация",
|
"register": "Регистрация",
|
||||||
|
|||||||
@@ -62,6 +62,79 @@
|
|||||||
"newUserReply": "用户回复了工单:{{title}}",
|
"newUserReply": "用户回复了工单:{{title}}",
|
||||||
"newUserReplyTitle": "用户回复"
|
"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": {
|
"auth": {
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"register": "注册",
|
"register": "注册",
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
import ConnectionModal from '../components/ConnectionModal';
|
import ConnectionModal from '../components/ConnectionModal';
|
||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
|
||||||
// Helper to extract error message from axios/api errors
|
// Helper to extract error message from axios/api errors
|
||||||
@@ -279,6 +280,21 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Tariff switch preview
|
// Tariff switch preview
|
||||||
const [switchTariffId, setSwitchTariffId] = useState<number | null>(null);
|
const [switchTariffId, setSwitchTariffId] = useState<number | null>(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({
|
const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({
|
||||||
queryKey: ['tariff-switch-preview', switchTariffId],
|
queryKey: ['tariff-switch-preview', switchTariffId],
|
||||||
queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!),
|
queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!),
|
||||||
|
|||||||
65
src/store/successNotification.ts
Normal file
65
src/store/successNotification.ts
Normal file
@@ -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<SuccessNotificationState>((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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user