From ca227f39759752c3208f5c4dfb622c9ee7d5858b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 30 Jan 2026 19:04:45 +0300 Subject: [PATCH] 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": "注册",