diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5630838..92212a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,8 +10,26 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm run test
+
build:
- needs: lint
+ needs: [lint, test]
runs-on: ubuntu-latest
steps:
- name: Checkout code
diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts
index bc9a053..1bc20f2 100644
--- a/src/api/adminUsers.ts
+++ b/src/api/adminUsers.ts
@@ -535,6 +535,15 @@ export const adminUsersApi = {
return response.data;
},
+ // Send direct Telegram message to user via bot (parity with bot's admin action)
+ sendMessage: async (
+ userId: number,
+ text: string,
+ ): Promise<{ success: boolean; message: string }> => {
+ const response = await apiClient.post(`/cabinet/admin/users/${userId}/send-message`, { text });
+ return response.data;
+ },
+
// Update restrictions
updateRestrictions: async (
userId: number,
diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts
index 32e56b7..e459f1e 100644
--- a/src/api/promocodes.ts
+++ b/src/api/promocodes.ts
@@ -7,7 +7,8 @@ export type PromoCodeType =
| 'subscription_days'
| 'trial_subscription'
| 'promo_group'
- | 'discount';
+ | 'discount'
+ | 'balance_and_days';
export interface PromoCode {
id: number;
diff --git a/src/api/referralNetwork.ts b/src/api/referralNetwork.ts
index 9751d08..5f9a592 100644
--- a/src/api/referralNetwork.ts
+++ b/src/api/referralNetwork.ts
@@ -14,6 +14,11 @@ export const referralNetworkApi = {
return response.data;
},
+ getFullGraph: async (): Promise => {
+ const response = await apiClient.get('/cabinet/admin/referral-network/');
+ return response.data;
+ },
+
getScopedGraph: async (selections: ScopeSelection[]): Promise => {
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);
diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx
index 06d37b7..b08e68c 100644
--- a/src/components/PaymentMethodIcon.tsx
+++ b/src/components/PaymentMethodIcon.tsx
@@ -367,6 +367,28 @@ export default function PaymentMethodIcon({
);
}
+ case 'cispay': {
+ const cispayGradId = `${uid}-cispay`;
+ return (
+
+ );
+ }
+
case 'donut': {
const donutBgGradId = `${uid}-donut-bg`;
const donutGlazeGradId = `${uid}-donut-glaze`;
diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx
index 31cc36d..cf71da0 100644
--- a/src/components/SuccessNotificationModal.tsx
+++ b/src/components/SuccessNotificationModal.tsx
@@ -3,6 +3,7 @@
* Shows prominent success messages for balance top-ups and subscription purchases.
*/
+import { uiLocale } from '@/utils/uiLocale';
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -96,7 +97,7 @@ export default function SuccessNotificationModal() {
// Format expiry date
const formattedExpiry = data.expiresAt
- ? new Date(data.expiresAt).toLocaleDateString(undefined, {
+ ? new Date(data.expiresAt).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts
index 7a1b1a1..207b361 100644
--- a/src/components/admin/constants.ts
+++ b/src/components/admin/constants.ts
@@ -58,6 +58,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
{ id: 'payments_etoplatezhi', categories: ['ETOPLATEZHI'] },
{ id: 'payments_antilopay', categories: ['ANTILOPAY'] },
{ id: 'payments_jupiter', categories: ['JUPITER'] },
+ { id: 'payments_cispay', categories: ['CISPAY'] },
{ id: 'payments_donut', categories: ['DONUT'] },
{ id: 'payments_lava', categories: ['LAVA'] },
{ id: 'payments_apple_iap', categories: ['APPLE_IAP'] },
diff --git a/src/components/admin/userDetail/InfoTab.tsx b/src/components/admin/userDetail/InfoTab.tsx
index 7438ae3..22ecf6e 100644
--- a/src/components/admin/userDetail/InfoTab.tsx
+++ b/src/components/admin/userDetail/InfoTab.tsx
@@ -1,12 +1,15 @@
+import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
+import { useNotify } from '../../../platform/hooks/useNotify';
import { useCurrency } from '../../../hooks/useCurrency';
import { createNumberInputHandler } from '../../../utils/inputHelpers';
-import type {
- UserDetailResponse,
- UserListItem,
- UserPanelInfo,
- UserSubscriptionInfo,
+import {
+ adminUsersApi,
+ type UserDetailResponse,
+ type UserListItem,
+ type UserPanelInfo,
+ type UserSubscriptionInfo,
} from '../../../api/adminUsers';
import type { PromoGroup } from '../../../api/promocodes';
import { ServerIcon } from '@/components/icons';
@@ -92,6 +95,12 @@ export function InfoTab(props: InfoTabProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const navigate = useNavigate();
+ const notify = useNotify();
+
+ // «Отправить сообщение» — паритет с бот-кнопкой в карточке юзера
+ const [sendMsgOpen, setSendMsgOpen] = useState(false);
+ const [sendMsgText, setSendMsgText] = useState('');
+ const [sendMsgLoading, setSendMsgLoading] = useState(false);
const {
user,
hasPermission,
@@ -124,6 +133,32 @@ export function InfoTab(props: InfoTabProps) {
onFullDeleteUser,
} = props;
+ const handleSendMessage = async () => {
+ const text = sendMsgText.trim();
+ if (!text || sendMsgLoading) return;
+ setSendMsgLoading(true);
+ try {
+ await adminUsersApi.sendMessage(user.id, text);
+ notify.success(t('admin.users.sendMessage.success'), t('common.success'));
+ setSendMsgOpen(false);
+ setSendMsgText('');
+ } catch (err) {
+ const detail = (
+ err as { response?: { data?: { detail?: { code?: string; message?: string } | string } } }
+ )?.response?.data?.detail;
+ const code = typeof detail === 'object' ? detail?.code : undefined;
+ const known = ['no_telegram_id', 'forbidden', 'bad_request'];
+ const message =
+ code && known.includes(code)
+ ? t(`admin.users.sendMessage.errors.${code}`)
+ : (typeof detail === 'object' ? detail?.message : detail) ||
+ t('admin.users.userActions.error');
+ notify.error(message, t('common.error'));
+ } finally {
+ setSendMsgLoading(false);
+ }
+ };
+
return (
{/* Status */}
@@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) {
{t('admin.users.detail.actions.title')}
+ {hasPermission('users:send_message') && (
+
+ )}
+
+ {/* Send message modal */}
+ {sendMsgOpen && (
+
+
!sendMsgLoading && setSendMsgOpen(false)}
+ aria-hidden="true"
+ />
+
+
+ {t('admin.users.sendMessage.title')}
+
+
+
+ )}
);
}
diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx
index e78d044..f79ee3a 100644
--- a/src/components/dashboard/SubscriptionCardActive.tsx
+++ b/src/components/dashboard/SubscriptionCardActive.tsx
@@ -1,7 +1,8 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { Link } from 'react-router';
-import { UseMutationResult } from '@tanstack/react-query';
+import type { UseMutationResult } from '@tanstack/react-query';
import TrafficProgressBar from './TrafficProgressBar';
import Sparkline from './Sparkline';
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
@@ -48,7 +49,7 @@ export default function SubscriptionCardActive({
const isAtDeviceLimit =
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
- const formattedDate = new Date(subscription.end_date).toLocaleDateString();
+ const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
const daysLeft = subscription.days_left;
// Sparkline placeholder data (hidden until API provides daily usage)
diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx
index 5431944..f1c83bb 100644
--- a/src/components/dashboard/SubscriptionCardExpired.tsx
+++ b/src/components/dashboard/SubscriptionCardExpired.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useLocation } from 'react-router';
@@ -37,7 +38,7 @@ export default function SubscriptionCardExpired({
const [isRenewing, setIsRenewing] = useState(false);
const [renewError, setRenewError] = useState(null);
- const formattedDate = new Date(subscription.end_date).toLocaleDateString();
+ const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
// Detect limited (traffic exhausted) state
const isLimited = subscription.is_limited;
diff --git a/src/constants/paymentMethods.ts b/src/constants/paymentMethods.ts
index 8154c4f..13eceb4 100644
--- a/src/constants/paymentMethods.ts
+++ b/src/constants/paymentMethods.ts
@@ -22,6 +22,7 @@ export const METHOD_LABELS: Record = {
etoplatezhi: 'Etoplatezhi',
antilopay: 'Antilopay',
jupiter: 'Jupiter',
+ cispay: 'CisPay',
donut: 'Donut',
lava: 'Lava',
apple_iap: 'Apple In-App Purchase',
diff --git a/src/locales/en.json b/src/locales/en.json
index 160c585..774cbb4 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -238,6 +238,15 @@
"failed": "Verification Failed",
"goToLogin": "Go to Login"
},
+ "resetPassword": {
+ "title": "Set new password",
+ "enterNewPassword": "Enter your new password below.",
+ "success": "Password changed!",
+ "redirectingToLogin": "Redirecting to login...",
+ "setPassword": "Set new password",
+ "invalidToken": "Invalid reset link",
+ "tokenExpiredOrInvalid": "This password reset link is invalid or has expired."
+ },
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome, {{name}}!",
@@ -291,6 +300,7 @@
"traffic": "Traffic",
"devices": "Devices",
"expiredDate": "Expired",
+ "expiredDate_trial": "Expired",
"activeUntil": "Active until"
},
"suspended": {
@@ -746,6 +756,9 @@
"jupiter": {
"description": "Pay via SBP through Jupiter (FPGate P2P)"
},
+ "cispay": {
+ "description": "Pay by card or via SBP (cisPay)"
+ },
"donut": {
"description": "Pay by card or SBP through Donut P2P"
},
@@ -1498,7 +1511,10 @@
"removeItem": "Remove {{label}}",
"clearAll": "Clear all selections",
"addScope": "Add scope",
- "maxReached": "Maximum {{max}} items"
+ "maxReached": "Maximum {{max}} items",
+ "fullNetwork": "Full network",
+ "fullNetworkHint": "Show the entire graph",
+ "removeFullNetwork": "Turn off full network mode"
},
"loading": "Loading graph...",
"error": "Failed to load network data",
@@ -1611,7 +1627,7 @@
},
"paymentMethods": {
"title": "Payment Methods",
- "description": "Configure display order and conditions",
+ "subtitle": "Configure display order and conditions",
"enabled": "On",
"disabled": "Off",
"notConfigured": "Not configured",
@@ -1627,6 +1643,8 @@
"openUrlDirectHint": "ON — the payment link opens immediately, without the intermediate button panel. OFF — an \"Open payment\" button is shown. Inside Telegram the link opens in the external browser either way (otherwise paying via a bank app/SBP fails); after payment the user returns to the result page. Stars/CryptoBot (t.me/ links) always open natively and ignore this flag.",
"displayName": "Display name",
"displayNameHint": "Leave empty for default name",
+ "description": "Description",
+ "descriptionHint": "Shown under the method name. Empty for default text",
"subOptions": "Payment options",
"minAmount": "Min amount (kopeks)",
"maxAmount": "Max amount (kopeks)",
@@ -1693,6 +1711,7 @@
"totalUpload": "Upload",
"totalTraffic": "Total",
"online": "online",
+ "realtimeTitle": "Realtime traffic",
"inbounds": "Inbounds",
"outbounds": "Outbounds"
},
@@ -2175,6 +2194,7 @@
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
+ "payments_cispay": "CisPay",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
@@ -3133,7 +3153,8 @@
"subscriptionDays": "Subscription days",
"trialSubscription": "Trial subscription",
"promoGroup": "Discount group",
- "discount": "Discount %"
+ "discount": "Discount %",
+ "balanceAndDays": "Balance + days"
},
"modal": {
"editPromocode": "Edit promo code",
@@ -3151,6 +3172,11 @@
"typeTrialSubscription": "Trial subscription",
"typePromoGroup": "Discount group",
"typeDiscount": "Percentage discount",
+ "typeBonusSet": "Bonus set (balance / days / promo group)",
+ "bonusComposition": "Promo code contents",
+ "includeBalance": "Balance bonus",
+ "includeDays": "Subscription days",
+ "includePromoGroup": "Promo group",
"bonusAmount": "Bonus amount",
"rub": "RUB",
"daysCount": "Number of days",
@@ -3242,6 +3268,7 @@
},
"validation": {
"codeRequired": "Enter promo code",
+ "bonusSetEmpty": "Select at least one bonus for the promo code",
"balanceRequired": "Bonus amount must be greater than 0",
"daysRequired": "Number of days must be greater than 0",
"groupRequired": "Select a discount group",
@@ -3342,6 +3369,20 @@
"confirm": {
"block": "Block this user?"
},
+ "sendMessage": {
+ "button": "✉️ Send message",
+ "title": "Send message to user",
+ "placeholder": "Text the bot will send to the user (HTML supported)",
+ "send": "Send",
+ "sending": "Sending...",
+ "success": "Message sent to user",
+ "noTelegram": "This user is registered by email only and cannot receive Telegram messages",
+ "errors": {
+ "no_telegram_id": "This user is registered by email only and cannot receive Telegram messages",
+ "forbidden": "The user has blocked the bot or cannot receive messages",
+ "bad_request": "Telegram rejected the message. Check the text (HTML markup) and try again"
+ }
+ },
"userActions": {
"delete": "Delete user",
"resetTrial": "Reset trial",
@@ -5004,7 +5045,7 @@
"test": "Test",
"activating": "Activating...",
"activate": "Activate",
- "serverAccess": "Server access"
+ "serverAccess": "Access to {{count}} servers"
},
"deactivate": {
"button": "Cancel promo code",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 0e73d2a..518fe03 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -633,6 +633,9 @@
"jupiter": {
"description": "پرداخت SBP از طریق Jupiter (FPGate P2P)"
},
+ "cispay": {
+ "description": "پرداخت با کارت یا SBP از طریق cisPay"
+ },
"donut": {
"description": "پرداخت با کارت یا SBP از طریق Donut P2P"
},
@@ -1854,6 +1857,7 @@
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
+ "payments_cispay": "CisPay",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
@@ -2271,7 +2275,7 @@
},
"paymentMethods": {
"title": "روشهای پرداخت",
- "description": "پیکربندی ترتیب و شرایط نمایش",
+ "subtitle": "پیکربندی ترتیب و شرایط نمایش",
"enabled": "روشن",
"disabled": "خاموش",
"notConfigured": "تنظیم نشده",
@@ -2287,6 +2291,8 @@
"openUrlDirectHint": "روشن — لینک پرداخت بلافاصله باز میشود، بدون پنل دکمهٔ میانی. خاموش — دکمهٔ «باز کردن پرداخت» نمایش داده میشود. داخل تلگرام لینک در هر دو حالت در مرورگر خارجی باز میشود (در غیر این صورت پرداخت از طریق اپ بانکی/SBP کار نمیکند)؛ پس از پرداخت کاربر به صفحهٔ نتیجه بازمیگردد. Stars/CryptoBot (لینکهای t.me/) همیشه بهصورت نیتیو باز میشوند و این پرچم روی آنها اعمال نمیشود.",
"displayName": "نام نمایشی",
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
+ "description": "توضیحات",
+ "descriptionHint": "زیر نام روش پرداخت نمایش داده میشود. خالی — متن پیشفرض",
"subOptions": "گزینههای پرداخت",
"minAmount": "حداقل مبلغ (کپک)",
"maxAmount": "حداکثر مبلغ (کپک)",
@@ -2669,7 +2675,8 @@
"subscriptionDays": "روزهای اشتراک",
"trialSubscription": "اشتراک آزمایشی",
"promoGroup": "گروه تخفیف",
- "discount": "تخفیف %"
+ "discount": "تخفیف %",
+ "balanceAndDays": "موجودی + روزها"
},
"modal": {
"editPromocode": "ویرایش کد تخفیف",
@@ -2687,6 +2694,11 @@
"typeTrialSubscription": "اشتراک آزمایشی",
"typePromoGroup": "گروه تخفیف",
"typeDiscount": "تخفیف درصدی",
+ "typeBonusSet": "مجموعه پاداش (موجودی / روزها / گروه تخفیف)",
+ "bonusComposition": "محتوای کد تخفیف",
+ "includeBalance": "پاداش موجودی",
+ "includeDays": "روزهای اشتراک",
+ "includePromoGroup": "گروه تخفیف",
"bonusAmount": "مبلغ پاداش",
"rub": "روبل",
"daysCount": "تعداد روزها",
@@ -2776,6 +2788,7 @@
},
"validation": {
"codeRequired": "کد تخفیف را وارد کنید",
+ "bonusSetEmpty": "حداقل یک پاداش برای کد تخفیف انتخاب کنید",
"balanceRequired": "مبلغ پاداش باید بیشتر از 0 باشد",
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
"groupRequired": "یک گروه تخفیف انتخاب کنید",
@@ -3153,6 +3166,20 @@
},
"notFound": "کاربر یافت نشد",
"purchaseCount": "تعداد خرید: {{count}}",
+ "sendMessage": {
+ "button": "✉️ ارسال پیام",
+ "title": "ارسال پیام به کاربر",
+ "placeholder": "متنی که ربات برای کاربر ارسال میکند (HTML پشتیبانی میشود)",
+ "send": "ارسال",
+ "sending": "در حال ارسال...",
+ "success": "پیام برای کاربر ارسال شد",
+ "noTelegram": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
+ "errors": {
+ "no_telegram_id": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
+ "forbidden": "کاربر ربات را مسدود کرده یا نمیتواند پیام دریافت کند",
+ "bad_request": "تلگرام پیام را رد کرد. متن (نشانهگذاری HTML) را بررسی کرده و دوباره تلاش کنید"
+ }
+ },
"userActions": {
"delete": "حذف کاربر",
"disable": "غیرفعالسازی",
diff --git a/src/locales/locales.test.ts b/src/locales/locales.test.ts
new file mode 100644
index 0000000..c3d6827
--- /dev/null
+++ b/src/locales/locales.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest';
+import en from './en.json';
+import ru from './ru.json';
+
+/**
+ * Синхронность en/ru локалей. i18next настроен с fallbackLng: 'ru' — ключ,
+ * отсутствующий в en.json, отдаёт англоязычным пользователям РУССКИЙ текст
+ * (а не инлайн-дефолт из кода). Так весь namespace resetPassword.* уехал в
+ * прод по-русски. Тест ловит новые дыры при добавлении ключей в одну локаль.
+ */
+
+// Русскоязычные словари бэкенд-настроек — осознанно только в ru
+// (en падает на сырые имена настроек, это админ-экран).
+const RU_ONLY_NAMESPACES = [
+ 'admin.settings.settingNames.',
+ 'admin.settings.categories.',
+ 'admin.settings.presets.',
+];
+
+// Известные дыры. #489 смержен, realtimeTitle переведён — список пуст.
+const KNOWN_MISSING_IN_EN = new Set([
+ // Пусто — так и держать: новые ключи переводите в en.json, а не вносите сюда.
+]);
+
+const KNOWN_PLACEHOLDER_MISMATCHES = new Set([]);
+
+// Плюральные категории i18next: ru использует _one/_few/_many, en — _one/_other.
+// Сравниваем БАЗОВЫЕ ключи (без плюрального суффикса); context-варианты
+// (напр. _trial) — самостоятельные ключи и обязаны существовать в обеих локалях.
+const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
+
+type Tree = { [key: string]: Tree | string };
+
+function flatten(tree: Tree, prefix = ''): Map {
+ const out = new Map();
+ for (const [key, value] of Object.entries(tree)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === 'string') {
+ out.set(path, value);
+ } else {
+ for (const [k, v] of flatten(value, path)) {
+ out.set(k, v);
+ }
+ }
+ }
+ return out;
+}
+
+const enFlat = flatten(en as Tree);
+const ruFlat = flatten(ru as Tree);
+
+function baseKeys(flat: Map): Set {
+ return new Set([...flat.keys()].map((k) => k.replace(PLURAL_SUFFIX, '')));
+}
+
+const enBases = baseKeys(enFlat);
+const ruBases = baseKeys(ruFlat);
+
+describe('синхронность локалей en/ru', () => {
+ it('каждый en-ключ существует в ru', () => {
+ const missing = [...enBases].filter((k) => !ruBases.has(k));
+ expect(missing).toEqual([]);
+ });
+
+ it('каждый ru-ключ существует в en (кроме ru-only словарей настроек)', () => {
+ const missing = [...ruBases].filter(
+ (k) =>
+ !enBases.has(k) &&
+ !RU_ONLY_NAMESPACES.some((ns) => k.startsWith(ns)) &&
+ !KNOWN_MISSING_IN_EN.has(k),
+ );
+ expect(missing).toEqual([]);
+ });
+
+ it('плейсхолдеры {{...}} совпадают в общих ключах', () => {
+ const PH = /\{\{[^}]+\}\}/g;
+ const mismatches: string[] = [];
+ for (const [key, ruValue] of ruFlat) {
+ const enValue = enFlat.get(key);
+ if (enValue === undefined || KNOWN_PLACEHOLDER_MISMATCHES.has(key)) {
+ continue;
+ }
+ const ruPh = (ruValue.match(PH) ?? []).sort().join(',');
+ const enPh = (enValue.match(PH) ?? []).sort().join(',');
+ if (ruPh !== enPh) {
+ mismatches.push(`${key}: ru=[${ruPh}] en=[${enPh}]`);
+ }
+ }
+ expect(mismatches).toEqual([]);
+ });
+});
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 79f6820..5424442 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -775,6 +775,9 @@
"jupiter": {
"description": "Оплата СБП через Jupiter (FPGate P2P)"
},
+ "cispay": {
+ "description": "Оплата картой или через СБП (cisPay)"
+ },
"donut": {
"description": "Оплата картой и СБП через Donut P2P"
},
@@ -1520,7 +1523,10 @@
"removeItem": "Удалить {{label}}",
"clearAll": "Очистить все выбранные",
"addScope": "Добавить область",
- "maxReached": "Максимум {{max}} элементов"
+ "maxReached": "Максимум {{max}} элементов",
+ "fullNetwork": "Вся сеть",
+ "fullNetworkHint": "Показать граф целиком",
+ "removeFullNetwork": "Выключить режим полной сети"
},
"loading": "Загрузка графа...",
"error": "Ошибка загрузки данных сети",
@@ -1633,7 +1639,7 @@
},
"paymentMethods": {
"title": "Платёжные методы",
- "description": "Настройка порядка и условий отображения",
+ "subtitle": "Настройка порядка и условий отображения",
"enabled": "Вкл",
"disabled": "Выкл",
"notConfigured": "Не настроен",
@@ -1649,6 +1655,8 @@
"openUrlDirectHint": "ВКЛ — ссылка оплаты открывается сразу, без промежуточной панели с кнопкой. ВЫКЛ — показывается кнопка «Открыть оплату». В Telegram ссылка в обоих случаях открывается во внешнем браузере (иначе оплата через банковское приложение/СБП не срабатывает), после оплаты — возврат на страницу результата. Stars/CryptoBot (t.me/-ссылки) всегда открываются нативно, флаг к ним не применяется.",
"displayName": "Отображаемое имя",
"displayNameHint": "Оставьте пустым для имени по умолчанию",
+ "description": "Описание",
+ "descriptionHint": "Показывается под названием метода. Пусто — текст по умолчанию",
"subOptions": "Варианты оплаты",
"minAmount": "Мин. сумма (коп.)",
"maxAmount": "Макс. сумма (коп.)",
@@ -2193,6 +2201,7 @@
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
+ "payments_cispay": "CisPay",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
@@ -2924,6 +2933,7 @@
"namePlaceholder": "Введите название тарифа",
"description": "Описание",
"descriptionPlaceholder": "Краткое описание тарифа",
+ "descriptionPlaceholder2": "Краткое описание тарифа",
"trafficLimit": "Лимит трафика",
"trafficHint": "0 = безлимитный трафик",
"deviceLimit": "Лимит устройств",
@@ -3537,7 +3547,8 @@
"subscriptionDays": "Дни подписки",
"trialSubscription": "Тестовая подписка",
"promoGroup": "Группа скидок",
- "discount": "Скидка %"
+ "discount": "Скидка %",
+ "balanceAndDays": "Баланс + дни"
},
"modal": {
"editPromocode": "Редактирование промокода",
@@ -3555,6 +3566,11 @@
"typeTrialSubscription": "Тестовая подписка",
"typePromoGroup": "Группа скидок",
"typeDiscount": "Процентная скидка",
+ "typeBonusSet": "Набор бонусов (баланс / дни / промогруппа)",
+ "bonusComposition": "Состав промокода",
+ "includeBalance": "Бонус на баланс",
+ "includeDays": "Дни подписки",
+ "includePromoGroup": "Промогруппа",
"bonusAmount": "Сумма бонуса",
"rub": "руб.",
"daysCount": "Количество дней",
@@ -3648,6 +3664,7 @@
},
"validation": {
"codeRequired": "Введите код промокода",
+ "bonusSetEmpty": "Выберите хотя бы один бонус в составе промокода",
"balanceRequired": "Сумма бонуса должна быть больше 0",
"daysRequired": "Количество дней должно быть больше 0",
"groupRequired": "Выберите группу скидок",
@@ -3749,6 +3766,20 @@
"confirm": {
"block": "Заблокировать пользователя?"
},
+ "sendMessage": {
+ "button": "✉️ Отправить сообщение",
+ "title": "Отправка сообщения пользователю",
+ "placeholder": "Текст, который бот отправит пользователю (поддерживается HTML)",
+ "send": "Отправить",
+ "sending": "Отправка...",
+ "success": "Сообщение отправлено пользователю",
+ "noTelegram": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
+ "errors": {
+ "no_telegram_id": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
+ "forbidden": "Пользователь заблокировал бота или не может получать сообщения",
+ "bad_request": "Telegram отклонил сообщение. Проверьте текст (HTML-разметку) и попробуйте ещё раз"
+ }
+ },
"userActions": {
"delete": "Удалить пользователя",
"resetTrial": "Сбросить триал",
@@ -3814,6 +3845,7 @@
"purchases": "Покупок",
"campaign": "Рекламная кампания",
"referralsList": "Список рефералов",
+ "referralEarnings": "Реф. доход",
"noReferrals": "Нет рефералов",
"panelConfig": "Конфигурация панели",
"trojanPassword": "Trojan пароль",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 7a4281f..d358bb2 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -633,6 +633,9 @@
"jupiter": {
"description": "通过 Jupiter (FPGate P2P) 使用 SBP 支付"
},
+ "cispay": {
+ "description": "通过 cisPay 使用银行卡或 SBP 支付"
+ },
"donut": {
"description": "通过 Donut P2P 使用银行卡或 SBP 支付"
},
@@ -1420,7 +1423,7 @@
},
"paymentMethods": {
"title": "支付方法",
- "description": "配置顺序和显示条件",
+ "subtitle": "配置顺序和显示条件",
"enabled": "开启",
"disabled": "关闭",
"notConfigured": "未配置",
@@ -1436,6 +1439,8 @@
"openUrlDirectHint": "开启 — 支付链接立即打开,不显示中间的按钮面板。关闭 — 显示「打开支付」按钮。在 Telegram 内两种情况都会在外部浏览器中打开链接(否则通过银行 App/SBP 付款会失败);支付完成后返回结果页。Stars/CryptoBot(t.me/ 链接)始终原生打开,不受此开关影响。",
"displayName": "显示名称",
"displayNameHint": "留空以使用默认名称",
+ "description": "描述",
+ "descriptionHint": "显示在支付方法名称下方。留空则使用默认文本",
"subOptions": "支付选项",
"minAmount": "最小金额(戈比)",
"maxAmount": "最大金额(戈比)",
@@ -1919,6 +1924,7 @@
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
+ "payments_cispay": "CisPay",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
@@ -2668,7 +2674,8 @@
"subscriptionDays": "订阅天数",
"trialSubscription": "试用订阅",
"promoGroup": "折扣组",
- "discount": "折扣 %"
+ "discount": "折扣 %",
+ "balanceAndDays": "余额 + 天数"
},
"modal": {
"editPromocode": "编辑促销码",
@@ -2686,6 +2693,11 @@
"typeTrialSubscription": "试用订阅",
"typePromoGroup": "折扣组",
"typeDiscount": "百分比折扣",
+ "typeBonusSet": "奖励组合(余额 / 天数 / 优惠组)",
+ "bonusComposition": "促销码内容",
+ "includeBalance": "余额奖励",
+ "includeDays": "订阅天数",
+ "includePromoGroup": "优惠组",
"bonusAmount": "奖金金额",
"rub": "卢布",
"daysCount": "天数",
@@ -2775,6 +2787,7 @@
},
"validation": {
"codeRequired": "请输入促销码",
+ "bonusSetEmpty": "请为促销码至少选择一项奖励",
"balanceRequired": "奖金金额必须大于0",
"daysRequired": "天数必须大于0",
"groupRequired": "请选择折扣组",
@@ -3152,6 +3165,20 @@
},
"notFound": "未找到用户",
"purchaseCount": "购买次数:{{count}}",
+ "sendMessage": {
+ "button": "✉️ 发送消息",
+ "title": "向用户发送消息",
+ "placeholder": "机器人将发送给用户的文本(支持 HTML)",
+ "send": "发送",
+ "sending": "发送中...",
+ "success": "消息已发送给用户",
+ "noTelegram": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
+ "errors": {
+ "no_telegram_id": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
+ "forbidden": "用户已屏蔽机器人或无法接收消息",
+ "bad_request": "Telegram 拒绝了该消息。请检查文本(HTML 标记)后重试"
+ }
+ },
"userActions": {
"delete": "删除用户",
"disable": "禁用",
diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx
index c11bbf5..77d044c 100644
--- a/src/pages/AdminPaymentMethodEdit.tsx
+++ b/src/pages/AdminPaymentMethodEdit.tsx
@@ -232,6 +232,7 @@ export default function AdminPaymentMethodEdit() {
// Local state for editing
const [isEnabled, setIsEnabled] = useState(false);
const [customName, setCustomName] = useState('');
+ const [customDesc, setCustomDesc] = useState('');
const [subOptions, setSubOptions] = useState>({});
const [minAmount, setMinAmount] = useState('');
const [maxAmount, setMaxAmount] = useState('');
@@ -249,6 +250,7 @@ export default function AdminPaymentMethodEdit() {
if (config) {
setIsEnabled(config.is_enabled);
setCustomName(config.display_name || '');
+ setCustomDesc(config.description || '');
setSubOptions(config.sub_options || {});
setMinAmount(config.min_amount_kopeks ?? '');
setMaxAmount(config.max_amount_kopeks ?? '');
@@ -290,6 +292,13 @@ export default function AdminPaymentMethodEdit() {
data.reset_display_name = true;
}
+ // Description
+ if (customDesc.trim()) {
+ data.description = customDesc.trim();
+ } else {
+ data.reset_description = true;
+ }
+
// Sub-options
if (config.available_sub_options) {
data.sub_options = subOptions;
@@ -478,6 +487,20 @@ export default function AdminPaymentMethodEdit() {
+ {/* Description */}
+
+
+
+
{/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && (
diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx
index 922154e..cd95711 100644
--- a/src/pages/AdminPaymentMethods.tsx
+++ b/src/pages/AdminPaymentMethods.tsx
@@ -219,7 +219,7 @@ export default function AdminPaymentMethods() {
)}
{t('admin.paymentMethods.title')}
-
{t('admin.paymentMethods.description')}
+
{t('admin.paymentMethods.subtitle')}
{orderChanged && (
diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx
index f36462a..f9975c3 100644
--- a/src/pages/AdminPromocodeCreate.tsx
+++ b/src/pages/AdminPromocodeCreate.tsx
@@ -6,11 +6,11 @@ import { DateField } from '../components/DateField';
import { createNumberInputHandler } from '../utils/inputHelpers';
import {
promocodesApi,
- PromoCodeDetail,
- PromoCodeType,
- PromoCodeCreateRequest,
- PromoCodeUpdateRequest,
- PromoGroup,
+ type PromoCodeDetail,
+ type PromoCodeType,
+ type PromoCodeCreateRequest,
+ type PromoCodeUpdateRequest,
+ type PromoGroup,
} from '../api/promocodes';
import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform';
@@ -34,9 +34,16 @@ export default function AdminPromocodeCreate() {
const { capabilities } = usePlatform();
const isEdit = !!id;
- // Form state
+ // Form state.
+ // Режим формы: «набор бонусов» (баланс/дни/промогруппа чекбоксами — тип
+ // промокода выводится из выбранной комбинации) либо особые типы, которые не
+ // комбинируются: триал и процентная скидка (скидка переиспользует те же
+ // колонки с другой семантикой — процент/часы).
const [code, setCode] = useState('');
- const [type, setType] = useState('balance');
+ const [mode, setMode] = useState<'bonus_set' | 'trial_subscription' | 'discount'>('bonus_set');
+ const [includeBalance, setIncludeBalance] = useState(true);
+ const [includeDays, setIncludeDays] = useState(false);
+ const [includeGroup, setIncludeGroup] = useState(false);
const [balanceBonusRubles, setBalanceBonusRubles] = useState(0);
const [subscriptionDays, setSubscriptionDays] = useState(0);
const [maxUses, setMaxUses] = useState(1);
@@ -58,7 +65,7 @@ export default function AdminPromocodeCreate() {
const { data: tariffsData } = useQuery({
queryKey: ['admin-tariffs-for-promo'],
queryFn: () => tariffsApi.getTariffs(true),
- enabled: type === 'trial_subscription',
+ enabled: mode === 'trial_subscription',
});
const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null;
@@ -74,7 +81,16 @@ export default function AdminPromocodeCreate() {
refetchOnWindowFocus: false,
select: useCallback((data: PromoCodeDetail) => {
setCode(data.code);
- setType(data.type);
+ if (data.type === 'trial_subscription' || data.type === 'discount') {
+ setMode(data.type);
+ } else {
+ setMode('bonus_set');
+ setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days');
+ setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days');
+ // Промогруппа комбинируется с любым составом (bэкенд назначает её
+ // независимо от типа), поэтому чекбокс — по факту наличия группы
+ setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
+ }
// For discount type, balance_bonus_kopeks is percentage directly
// For balance type, balance_bonus_kopeks needs to be converted to rubles
if (data.type === 'discount') {
@@ -112,6 +128,20 @@ export default function AdminPromocodeCreate() {
},
});
+ // Тип промокода выводится из режима и выбранных чекбоксов. «Только
+ // промогруппа» — легаси-тип promo_group; группа в комбинации с балансом/днями
+ // едет через promo_group_id при любом типе (бэкенд применяет её независимо).
+ const derivedType: PromoCodeType =
+ mode === 'bonus_set'
+ ? includeBalance && includeDays
+ ? 'balance_and_days'
+ : includeBalance
+ ? 'balance'
+ : includeDays
+ ? 'subscription_days'
+ : 'promo_group'
+ : mode;
+
const handleSubmit = () => {
// For discount: balance_bonus_kopeks = percent (integer), subscription_days = hours
// For balance: balance_bonus_kopeks = rubles * 100
@@ -121,12 +151,19 @@ export default function AdminPromocodeCreate() {
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
code: code.trim().toUpperCase(),
- type,
+ type: derivedType,
balance_bonus_kopeks:
- type === 'discount'
+ mode === 'discount'
? Math.round(balanceValue) // percent as integer
- : Math.round(balanceValue * 100), // rubles to kopeks
- subscription_days: daysValue,
+ : mode === 'bonus_set' && includeBalance
+ ? Math.round(balanceValue * 100) // rubles to kopeks
+ : 0,
+ subscription_days:
+ mode === 'discount' ||
+ mode === 'trial_subscription' ||
+ (mode === 'bonus_set' && includeDays)
+ ? daysValue
+ : 0,
max_uses: maxUsesValue,
is_active: isActive,
first_purchase_only: firstPurchaseOnly,
@@ -136,8 +173,8 @@ export default function AdminPromocodeCreate() {
// (the START of the day) — for a GMT+3 admin that made a code picked for
// "today" already expired by 3am, surfacing as a bogus "expired" error.
valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null,
- promo_group_id: type === 'promo_group' ? promoGroupId : null,
- ...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
+ promo_group_id: mode === 'bonus_set' && includeGroup ? promoGroupId : null,
+ ...(mode === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
};
if (isEdit) {
@@ -154,41 +191,41 @@ export default function AdminPromocodeCreate() {
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
- const isValid = (): boolean => {
- if (!isCodeValid) return false;
- if (type === 'balance' && balanceValue <= 0) return false;
- if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0)
- return false;
- if (type === 'promo_group' && !promoGroupId) return false;
- // For discount, validity hours of 0 = "until first purchase" (perpetual) — allowed.
- if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue < 0))
- return false;
- return true;
- };
-
// Collect validation errors for display
const validationErrors: string[] = [];
if (!isCodeValid) {
validationErrors.push('codeRequired');
}
- if (type === 'balance' && balanceValue <= 0) {
- validationErrors.push('balanceRequired');
+ if (mode === 'bonus_set') {
+ if (!includeBalance && !includeDays && !includeGroup) {
+ validationErrors.push('bonusSetEmpty');
+ }
+ if (includeBalance && balanceValue <= 0) {
+ validationErrors.push('balanceRequired');
+ }
+ if (includeDays && daysValue <= 0) {
+ validationErrors.push('daysRequired');
+ }
+ if (includeGroup && !promoGroupId) {
+ validationErrors.push('groupRequired');
+ }
}
- if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) {
+ if (mode === 'trial_subscription' && daysValue <= 0) {
validationErrors.push('daysRequired');
}
- if (type === 'promo_group' && !promoGroupId) {
- validationErrors.push('groupRequired');
- }
- if (type === 'discount') {
+ if (mode === 'discount') {
if (balanceValue <= 0 || balanceValue > 100) {
validationErrors.push('discountPercentInvalid');
}
- if (daysValue <= 0) {
+ // 0 часов = «бессрочно до первой покупки» — разрешено (как в isValid до
+ // унификации; раньше isValid и список ошибок противоречили друг другу)
+ if (daysValue < 0) {
validationErrors.push('discountHoursRequired');
}
}
+ const isValid = (): boolean => validationErrors.length === 0;
+
// Loading state
if (isEdit && isLoadingPromocode) {
return (
@@ -247,24 +284,59 @@ export default function AdminPromocodeCreate() {
+ {/* Состав набора бонусов — любая комбинация чекбоксами */}
+ {mode === 'bonus_set' && (
+
+
+ {t('admin.promocodes.form.bonusComposition')}
+ *
+
+
+ {(
+ [
+ ['includeBalance', includeBalance, setIncludeBalance] as const,
+ ['includeDays', includeDays, setIncludeDays] as const,
+ ['includePromoGroup', includeGroup, setIncludeGroup] as const,
+ ] as const
+ ).map(([key, checked, setChecked]) => (
+
+ ))}
+
+
+ )}
+
{/* Type-specific fields */}
- {type === 'balance' && (
+ {mode === 'bonus_set' && includeBalance && (
@@ -566,7 +567,7 @@ export default function Info() {
}
const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat('ru-RU', {
+ return new Intl.NumberFormat(uiLocale(), {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx
index 846a5ff..03f808b 100644
--- a/src/pages/MergeAccounts.tsx
+++ b/src/pages/MergeAccounts.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -88,7 +89,7 @@ function formatCountdown(seconds: number): string {
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
- return new Date(dateStr).toLocaleDateString(undefined, {
+ return new Date(dateStr).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
@@ -99,7 +100,7 @@ function formatDate(dateStr: string | null): string {
}
function formatBalance(kopeks: number): string {
- return Math.floor(kopeks / 100).toLocaleString();
+ return Math.floor(kopeks / 100).toLocaleString(uiLocale());
}
// -- Radio Indicator --
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index bbd387e..f7c9d71 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -303,7 +304,7 @@ export default function Profile() {
{t('profile.registeredAt')}
- {user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
+ {user?.created_at ? new Date(user.created_at).toLocaleDateString(uiLocale()) : '-'}
diff --git a/src/pages/PublicLegal.tsx b/src/pages/PublicLegal.tsx
index 20b8a46..f839ecf 100644
--- a/src/pages/PublicLegal.tsx
+++ b/src/pages/PublicLegal.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
@@ -77,7 +78,8 @@ export default function PublicLegal({ doc }: PublicLegalProps) {
{data.updated_at && (
- {t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
+ {t('info.updatedAt', 'Обновлено')}:{' '}
+ {new Date(data.updated_at).toLocaleDateString(uiLocale())}
)}
diff --git a/src/pages/ReferralNetwork/ReferralNetwork.tsx b/src/pages/ReferralNetwork/ReferralNetwork.tsx
index 21ed65f..1e13e3a 100644
--- a/src/pages/ReferralNetwork/ReferralNetwork.tsx
+++ b/src/pages/ReferralNetwork/ReferralNetwork.tsx
@@ -17,21 +17,26 @@ export function ReferralNetwork() {
const { t } = useTranslation();
const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
const scope = useReferralNetworkStore((s) => s.scope);
+ const isFullNetwork = useReferralNetworkStore((s) => s.isFullNetwork);
const addScope = useReferralNetworkStore((s) => s.addScope);
const removeScope = useReferralNetworkStore((s) => s.removeScope);
const clearScope = useReferralNetworkStore((s) => s.clearScope);
+ const setFullNetwork = useReferralNetworkStore((s) => s.setFullNetwork);
const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight();
- const hasScope = scope.length > 0;
+ const hasScope = isFullNetwork || scope.length > 0;
const {
data: networkData,
isLoading,
isError,
} = useQuery({
- queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
- queryFn: () => referralNetworkApi.getScopedGraph(scope),
+ queryKey: isFullNetwork
+ ? ['referral-network', 'full']
+ : ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
+ queryFn: () =>
+ isFullNetwork ? referralNetworkApi.getFullGraph() : referralNetworkApi.getScopedGraph(scope),
enabled: hasScope,
staleTime: 120_000,
});
@@ -60,9 +65,11 @@ export function ReferralNetwork() {
diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx
index b95ef94..f03225f 100644
--- a/src/pages/ReferralNetwork/components/NetworkStats.tsx
+++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import {
@@ -25,19 +26,19 @@ export function NetworkStats({ data, className }: NetworkStatsProps) {
}
tone="neutral"
/>
}
tone="neutral"
/>
}
tone="neutral"
/>
diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx
index fd35b6e..0036d10 100644
--- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx
+++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx
@@ -2,33 +2,41 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork';
-import { CheckIcon, CloseIcon, PlusIcon, SearchIcon } from '@/components/icons';
+import { CheckIcon, CloseIcon, PlusIcon, SearchIcon, ShareIcon } from '@/components/icons';
import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork';
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
interface ScopeSelectorProps {
value: ScopeSelection[];
+ isFullNetwork: boolean;
onAdd: (selection: ScopeSelection) => void;
onRemove: (type: ScopeSelection['type'], id: number) => void;
onClear: () => void;
+ onFullNetworkChange: (enabled: boolean) => void;
className?: string;
}
const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user'];
-const CHIP_COLORS: Record
= {
+// 'all' is the full-network mode rather than a scope entity, so it gets a
+// neutral chip instead of one of the three entity colours.
+type OptionType = ScopeType | 'all';
+
+const CHIP_COLORS: Record = {
campaign: 'bg-success-500/20 text-success-400',
partner: 'bg-warning-500/20 text-warning-400',
user: 'bg-accent-500/20 text-accent-400',
+ all: 'bg-dark-700 text-dark-100',
};
// Reuse CHIP_COLORS for avatar backgrounds (same palette)
const AVATAR_COLORS = CHIP_COLORS;
-const AVATAR_LETTERS: Record = {
+const AVATAR_GLYPHS: Record = {
campaign: 'C',
partner: 'P',
user: 'U',
+ all: ,
};
function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
@@ -44,7 +52,7 @@ function EmptyMessage({ text }: { text: string }) {
}
interface ScopeListItemProps {
- type: ScopeType;
+ type: OptionType;
selected: boolean;
onClick: () => void;
title: string;
@@ -65,7 +73,7 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop
- {selected ? : AVATAR_LETTERS[type]}
+ {selected ? : AVATAR_GLYPHS[type]}
{title}
@@ -76,7 +84,15 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop
);
}
-export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: ScopeSelectorProps) {
+export function ScopeSelector({
+ value,
+ isFullNetwork,
+ onAdd,
+ onRemove,
+ onClear,
+ onFullNetworkChange,
+ className,
+}: ScopeSelectorProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState
('campaign');
const [searchInput, setSearchInput] = useState('');
@@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
{/* Chips row + add trigger */}
{/* Selected chips */}
- {value.length > 0 && (
+ {(isFullNetwork || value.length > 0) && (
+ {isFullNetwork && (
+
+
+ {t('admin.referralNetwork.scope.fullNetwork')}
+
+
+
+ )}
{value.map((item) => (
- {/* List */}
+ {/* List — the full-network option leads every tab, since it is a mode
+ rather than an entity of the currently active tab. */}
+
+ onFullNetworkChange(!isFullNetwork)}
+ title={t('admin.referralNetwork.scope.fullNetwork')}
+ subtitle={t('admin.referralNetwork.scope.fullNetworkHint')}
+ />
+
{activeTab === 'campaign' && renderCampaignList()}
{activeTab === 'partner' && renderPartnerList()}
{activeTab === 'user' && renderUserList()}
diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
index f17be20..628c1cc 100644
--- a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
+++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork';
@@ -115,7 +116,7 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
{user.subscription_end && (
{t('admin.referralNetwork.user.validUntil', {
- date: new Date(user.subscription_end).toLocaleDateString(),
+ date: new Date(user.subscription_end).toLocaleDateString(uiLocale()),
})}
)}
diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts
index c7b5e1d..0b7ee14 100644
--- a/src/pages/ReferralNetwork/utils.ts
+++ b/src/pages/ReferralNetwork/utils.ts
@@ -1,10 +1,11 @@
+import { uiLocale } from '@/utils/uiLocale';
import type { SubscriptionStatus } from '@/types/referralNetwork';
/**
* Format kopeks to a human-readable ruble string.
*/
export function formatKopeksToRubles(kopeks: number): string {
- return `${(kopeks / 100).toLocaleString('ru-RU')}`;
+ return `${(kopeks / 100).toLocaleString(uiLocale())}`;
}
/**
diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx
index 052030b..6501df0 100644
--- a/src/pages/SavedCards.tsx
+++ b/src/pages/SavedCards.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -17,7 +18,7 @@ function formatCardDate(dateStr: string): string {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
- return date.toLocaleDateString();
+ return date.toLocaleDateString(uiLocale());
} catch {
return dateStr;
}
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 2392f8e..21cba8b 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -73,7 +74,7 @@ const CountdownTimer = memo(function CountdownTimer({
const isExpired = !isActive;
const isUrgent = countdown.days <= 3;
- const formattedDate = new Date(endDate).toLocaleDateString(undefined, {
+ const formattedDate = new Date(endDate).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
@@ -997,7 +998,7 @@ export default function Subscription() {
{t('subscription.trafficResetAt')}:{' '}
- {new Date(purchase.expires_at).toLocaleDateString(undefined, {
+ {new Date(purchase.expires_at).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@@ -1017,8 +1018,12 @@ export default function Subscription() {
/>
- {new Date(purchase.created_at).toLocaleDateString()}
- {new Date(purchase.expires_at).toLocaleDateString()}
+
+ {new Date(purchase.created_at).toLocaleDateString(uiLocale())}
+
+
+ {new Date(purchase.expires_at).toLocaleDateString(uiLocale())}
+
))}
@@ -1203,7 +1208,7 @@ export default function Subscription() {
{t('subscription.pause.pausedDescription')}{' '}
- {new Date(subscription.end_date).toLocaleDateString()} (
+ {new Date(subscription.end_date).toLocaleDateString(uiLocale())} (
{t('subscription.pause.days', { count: subscription.days_left })})
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx
index 048bda8..4bda888 100644
--- a/src/pages/Support.tsx
+++ b/src/pages/Support.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -411,7 +412,7 @@ export default function Support() {
- {new Date(ticket.updated_at).toLocaleDateString()}
+ {new Date(ticket.updated_at).toLocaleDateString(uiLocale())}
))}
@@ -558,7 +559,7 @@ export default function Support() {
{t('support.created')}{' '}
- {new Date(selectedTicket.created_at).toLocaleDateString()}
+ {new Date(selectedTicket.created_at).toLocaleDateString(uiLocale())}
@@ -587,7 +588,7 @@ export default function Support() {
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
- {new Date(msg.created_at).toLocaleString()}
+ {new Date(msg.created_at).toLocaleString(uiLocale())}
{msg.message_text && (
diff --git a/src/pages/TopUpMethodSelect.tsx b/src/pages/TopUpMethodSelect.tsx
index 0fd0a17..269ef34 100644
--- a/src/pages/TopUpMethodSelect.tsx
+++ b/src/pages/TopUpMethodSelect.tsx
@@ -74,12 +74,12 @@ export default function TopUpMethodSelect() {
- {translatedName || method.name}
+ {method.name || translatedName}
- {(translatedDesc || method.description) && (
+ {(method.description || translatedDesc) && (
- {translatedDesc || method.description}
+ {method.description || translatedDesc}
)}
diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx
index 074ea3a..313c0f5 100644
--- a/src/pages/Wheel.tsx
+++ b/src/pages/Wheel.tsx
@@ -1,3 +1,4 @@
+import { uiLocale } from '@/utils/uiLocale';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -798,7 +799,7 @@ export default function Wheel() {
{item.prize_display_name}
- {new Date(item.created_at).toLocaleDateString()}
+ {new Date(item.created_at).toLocaleDateString(uiLocale())}
diff --git a/src/store/referralNetwork.test.ts b/src/store/referralNetwork.test.ts
new file mode 100644
index 0000000..2f842f3
--- /dev/null
+++ b/src/store/referralNetwork.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { MAX_SCOPE_ITEMS, useReferralNetworkStore } from './referralNetwork';
+import type { ScopeSelection } from '@/types/referralNetwork';
+
+const INITIAL_STATE = useReferralNetworkStore.getState();
+
+function campaign(id: number): ScopeSelection {
+ return { type: 'campaign', id, label: `Campaign ${id}` };
+}
+
+describe('referralNetwork store', () => {
+ beforeEach(() => {
+ useReferralNetworkStore.setState(INITIAL_STATE, true);
+ });
+
+ it('starts with no scope and full network off', () => {
+ const state = useReferralNetworkStore.getState();
+ expect(state.scope).toEqual([]);
+ expect(state.isFullNetwork).toBe(false);
+ });
+
+ it('drops the selected scope when full network is enabled', () => {
+ const { addScope, setFullNetwork } = useReferralNetworkStore.getState();
+ addScope(campaign(1));
+ addScope(campaign(2));
+
+ setFullNetwork(true);
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.isFullNetwork).toBe(true);
+ expect(state.scope).toEqual([]);
+ });
+
+ it('leaves full network mode when a scope item is added', () => {
+ const { setFullNetwork, addScope } = useReferralNetworkStore.getState();
+ setFullNetwork(true);
+
+ addScope(campaign(1));
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.isFullNetwork).toBe(false);
+ expect(state.scope).toEqual([campaign(1)]);
+ });
+
+ it('resets the graph selection when switching to full network', () => {
+ const { setSelectedNode, setHighlightedNodes, setFullNetwork } =
+ useReferralNetworkStore.getState();
+ setSelectedNode({ type: 'user', id: 7 });
+ setHighlightedNodes(new Set(['user_7']));
+
+ setFullNetwork(true);
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.selectedNode).toBeNull();
+ expect(state.highlightedNodes.size).toBe(0);
+ });
+
+ it('turns full network off without leaving a stale scope', () => {
+ const { setFullNetwork } = useReferralNetworkStore.getState();
+ setFullNetwork(true);
+
+ setFullNetwork(false);
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.isFullNetwork).toBe(false);
+ expect(state.scope).toEqual([]);
+ });
+
+ it('clears both the scope and full network mode', () => {
+ const { setFullNetwork, clearScope } = useReferralNetworkStore.getState();
+ setFullNetwork(true);
+
+ clearScope();
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.isFullNetwork).toBe(false);
+ expect(state.scope).toEqual([]);
+ });
+
+ it('ignores duplicate scope items and keeps full network off', () => {
+ const { addScope } = useReferralNetworkStore.getState();
+ addScope(campaign(1));
+ addScope(campaign(1));
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.scope).toEqual([campaign(1)]);
+ expect(state.isFullNetwork).toBe(false);
+ });
+
+ it('does not exit full network mode when the scope is already full', () => {
+ const { addScope, setFullNetwork } = useReferralNetworkStore.getState();
+ for (let id = 1; id <= MAX_SCOPE_ITEMS; id++) {
+ addScope(campaign(id));
+ }
+ setFullNetwork(true);
+
+ // The cap is per-scope; with the scope dropped by full-network mode there is
+ // nothing to reject, so this add must switch the mode off and take effect.
+ addScope(campaign(999));
+
+ const state = useReferralNetworkStore.getState();
+ expect(state.isFullNetwork).toBe(false);
+ expect(state.scope).toEqual([campaign(999)]);
+ });
+});
diff --git a/src/store/referralNetwork.ts b/src/store/referralNetwork.ts
index 8501fdc..4b722ba 100644
--- a/src/store/referralNetwork.ts
+++ b/src/store/referralNetwork.ts
@@ -24,12 +24,14 @@ interface ReferralNetworkState {
highlightedNodes: Set;
filters: NetworkFilters;
scope: ScopeSelection[];
+ isFullNetwork: boolean;
setSelectedNode: (node: SelectedNode) => void;
setHoveredNode: (id: string | null) => void;
setHighlightedNodes: (nodes: Set) => void;
updateFilters: (filters: Partial) => void;
resetFilters: () => void;
+ setFullNetwork: (enabled: boolean) => void;
addScope: (item: ScopeSelection) => void;
removeScope: (type: ScopeSelection['type'], id: number) => void;
clearScope: () => void;
@@ -41,6 +43,7 @@ export const useReferralNetworkStore = create()((set) => (
highlightedNodes: new Set(),
filters: { ...DEFAULT_FILTERS },
scope: [],
+ isFullNetwork: false,
setSelectedNode: (node) => set({ selectedNode: node }),
setHoveredNode: (id) => set({ hoveredNodeId: id }),
@@ -53,6 +56,16 @@ export const useReferralNetworkStore = create()((set) => (
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
+ // Full network and a scope selection are mutually exclusive: the whole graph
+ // already contains every campaign, partner and user, so narrowing it by chips
+ // would be meaningless. Each mode therefore drops the other.
+ setFullNetwork: (enabled) =>
+ set({
+ isFullNetwork: enabled,
+ scope: [],
+ ...resetGraphState(),
+ }),
+
addScope: (item) =>
set((state) => {
const exists = state.scope.some((s) => s.type === item.type && s.id === item.id);
@@ -60,6 +73,7 @@ export const useReferralNetworkStore = create()((set) => (
if (state.scope.length >= MAX_SCOPE_ITEMS) return state;
return {
scope: [...state.scope, item],
+ isFullNetwork: false,
...resetGraphState(),
};
}),
@@ -76,6 +90,7 @@ export const useReferralNetworkStore = create()((set) => (
clearScope: () =>
set({
scope: [],
+ isFullNetwork: false,
...resetGraphState(),
}),
}));
diff --git a/src/types/index.ts b/src/types/index.ts
index 5e3deb2..9181884 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -689,6 +689,7 @@ export interface PaymentMethodConfig {
is_enabled: boolean;
display_name: string | null;
default_display_name: string;
+ description: string | null;
sub_options: Record | null;
available_sub_options: PaymentMethodSubOptionInfo[] | null;
quick_amounts: number[] | null;
diff --git a/src/utils/format.ts b/src/utils/format.ts
index 796a020..25bbb70 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -11,6 +11,7 @@ export function formatUptime(seconds: number): string {
import i18next from 'i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
+import { uiLocale } from './uiLocale';
const LANG_CURRENCY_MAP: Record<
string,
@@ -60,18 +61,10 @@ export function formatPrice(kopeks: number, lang?: string): string {
}
}
-const SHORT_DATE_LOCALE_MAP: Record = {
- ru: 'ru-RU',
- en: 'en-US',
- zh: 'zh-CN',
- fa: 'fa-IR',
-};
-
/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
export function formatShortDate(date: string | null): string {
if (!date) return '-';
- const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
- return new Date(date).toLocaleDateString(locale, {
+ return new Date(date).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
diff --git a/src/utils/uiLocale.test.ts b/src/utils/uiLocale.test.ts
new file mode 100644
index 0000000..7735a14
--- /dev/null
+++ b/src/utils/uiLocale.test.ts
@@ -0,0 +1,36 @@
+import i18next from 'i18next';
+import { beforeAll, describe, expect, it } from 'vitest';
+import { uiLocale } from './uiLocale';
+
+describe('uiLocale', () => {
+ beforeAll(async () => {
+ await i18next.init({ lng: 'ru', resources: {} });
+ });
+
+ it('маппит язык интерфейса в BCP-47 тег', async () => {
+ await i18next.changeLanguage('en');
+ expect(uiLocale()).toBe('en-US');
+ await i18next.changeLanguage('zh');
+ expect(uiLocale()).toBe('zh-CN');
+ await i18next.changeLanguage('fa');
+ expect(uiLocale()).toBe('fa-IR');
+ });
+
+ it('режет региональный суффикс языка', async () => {
+ await i18next.changeLanguage('en-GB');
+ expect(uiLocale()).toBe('en-US');
+ });
+
+ it('падает в ru-RU для неизвестного языка', async () => {
+ await i18next.changeLanguage('xx');
+ expect(uiLocale()).toBe('ru-RU');
+ });
+
+ it('дата форматируется по языку интерфейса, а не браузера', async () => {
+ await i18next.changeLanguage('en');
+ const date = new Date('2026-07-13T12:00:00Z');
+ expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('7/13/2026');
+ await i18next.changeLanguage('ru');
+ expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('13.07.2026');
+ });
+});
diff --git a/src/utils/uiLocale.ts b/src/utils/uiLocale.ts
new file mode 100644
index 0000000..30c337a
--- /dev/null
+++ b/src/utils/uiLocale.ts
@@ -0,0 +1,18 @@
+import i18next from 'i18next';
+
+const LOCALE_MAP: Record = {
+ ru: 'ru-RU',
+ en: 'en-US',
+ zh: 'zh-CN',
+ fa: 'fa-IR',
+};
+
+/**
+ * BCP-47 тег активного языка интерфейса для toLocale*-методов и Intl-API.
+ * Без него даты/числа форматируются локалью браузера (или жёстким 'ru-RU')
+ * и не реагируют на смену языка в приложении.
+ */
+export function uiLocale(): string {
+ const lang = (i18next.language || '').split('-')[0];
+ return LOCALE_MAP[lang] || 'ru-RU';
+}