Merge pull request #499 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-07-19 17:54:52 +03:00
committed by GitHub
43 changed files with 834 additions and 127 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -7,7 +7,8 @@ export type PromoCodeType =
| 'subscription_days'
| 'trial_subscription'
| 'promo_group'
| 'discount';
| 'discount'
| 'balance_and_days';
export interface PromoCode {
id: number;

View File

@@ -14,6 +14,11 @@ export const referralNetworkApi = {
return response.data;
},
getFullGraph: async (): Promise<NetworkGraphData> => {
const response = await apiClient.get('/cabinet/admin/referral-network/');
return response.data;
},
getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => {
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);

View File

@@ -367,6 +367,28 @@ export default function PaymentMethodIcon({
);
}
case 'cispay': {
const cispayGradId = `${uid}-cispay`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={cispayGradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#0d9488" />
<stop offset="100%" stopColor="#2563eb" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${cispayGradId})`} />
<rect x="6.5" y="14" width="17" height="12.5" rx="2.5" fill="#fff" opacity="0.95" />
<rect x="6.5" y="17" width="17" height="2.3" fill="#0d9488" opacity="0.85" />
<rect x="9.5" y="21.5" width="5" height="3" rx="0.8" fill="#2563eb" opacity="0.5" />
<g fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round">
<path d="M26 15a7 7 0 0 1 0 10" />
<path d="M26 17.6a3.8 3.8 0 0 1 0 4.8" />
</g>
</svg>
);
}
case 'donut': {
const donutBgGradId = `${uid}-donut-bg`;
const donutGlazeGradId = `${uid}-donut-glaze`;

View File

@@ -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',

View File

@@ -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'] },

View File

@@ -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 (
<div className="space-y-4">
{/* Status */}
@@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) {
{t('admin.users.detail.actions.title')}
</div>
<div className="grid grid-cols-2 gap-2">
{hasPermission('users:send_message') && (
<button
onClick={() => setSendMsgOpen(true)}
disabled={actionLoading || !user.telegram_id}
title={!user.telegram_id ? t('admin.users.sendMessage.noTelegram') : undefined}
className="col-span-2 rounded-lg bg-accent-500/15 px-3 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25 disabled:opacity-50"
>
{t('admin.users.sendMessage.button')}
</button>
)}
<button
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
disabled={actionLoading}
@@ -524,6 +569,55 @@ export function InfoTab(props: InfoTabProps) {
</button>
</div>
</div>
{/* Send message modal */}
{sendMsgOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-dark-950/60"
onClick={() => !sendMsgLoading && setSendMsgOpen(false)}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-message-title"
className="relative w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-5"
>
<h3 id="send-message-title" className="mb-3 text-base font-semibold text-dark-100">
{t('admin.users.sendMessage.title')}
</h3>
<textarea
value={sendMsgText}
onChange={(e) => setSendMsgText(e.target.value)}
maxLength={4096}
rows={5}
autoFocus
placeholder={t('admin.users.sendMessage.placeholder')}
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900/60 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
/>
<div className="mt-1 text-right text-xs text-dark-500">{sendMsgText.length}/4096</div>
<div className="mt-3 flex justify-end gap-2">
<button
onClick={() => setSendMsgOpen(false)}
disabled={sendMsgLoading}
className="rounded-lg bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleSendMessage}
disabled={sendMsgLoading || !sendMsgText.trim()}
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{sendMsgLoading
? t('admin.users.sendMessage.sending')
: t('admin.users.sendMessage.send')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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)

View File

@@ -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<string | null>(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;

View File

@@ -22,6 +22,7 @@ export const METHOD_LABELS: Record<string, string> = {
etoplatezhi: 'Etoplatezhi',
antilopay: 'Antilopay',
jupiter: 'Jupiter',
cispay: 'CisPay',
donut: 'Donut',
lava: 'Lava',
apple_iap: 'Apple In-App Purchase',

View File

@@ -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",

View File

@@ -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": "غیرفعال‌سازی",

View File

@@ -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<string>([
// Пусто — так и держать: новые ключи переводите в en.json, а не вносите сюда.
]);
const KNOWN_PLACEHOLDER_MISMATCHES = new Set<string>([]);
// Плюральные категории 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<string, string> {
const out = new Map<string, string>();
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<string, string>): Set<string> {
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([]);
});
});

View File

@@ -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 пароль",

View File

@@ -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": "禁用",

View File

@@ -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<Record<string, boolean>>({});
const [minAmount, setMinAmount] = useState<number | ''>('');
const [maxAmount, setMaxAmount] = useState<number | ''>('');
@@ -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() {
</p>
</div>
{/* Description */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.description')}
</label>
<textarea
value={customDesc}
onChange={(e) => setCustomDesc(e.target.value)}
rows={2}
className="input"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.paymentMethods.descriptionHint')}</p>
</div>
{/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && (
<div>

View File

@@ -219,7 +219,7 @@ export default function AdminPaymentMethods() {
)}
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.paymentMethods.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
<p className="text-sm text-dark-400">{t('admin.paymentMethods.subtitle')}</p>
</div>
</div>
{orderChanged && (

View File

@@ -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<PromoCodeType>('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<number | ''>(0);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
const [maxUses, setMaxUses] = useState<number | ''>(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() {
</label>
<select
id="pc-type"
value={type}
onChange={(e) => setType(e.target.value as PromoCodeType)}
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="input"
>
<option value="balance">{t('admin.promocodes.form.typeBalance')}</option>
<option value="subscription_days">
{t('admin.promocodes.form.typeSubscriptionDays')}
</option>
<option value="bonus_set">{t('admin.promocodes.form.typeBonusSet')}</option>
<option value="trial_subscription">
{t('admin.promocodes.form.typeTrialSubscription')}
</option>
<option value="promo_group">{t('admin.promocodes.form.typePromoGroup')}</option>
<option value="discount">{t('admin.promocodes.form.typeDiscount')}</option>
</select>
</div>
{/* Состав набора бонусов — любая комбинация чекбоксами */}
{mode === 'bonus_set' && (
<div>
<div className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.bonusComposition')}
<span className="text-error-400">*</span>
</div>
<div className="flex flex-col gap-2">
{(
[
['includeBalance', includeBalance, setIncludeBalance] as const,
['includeDays', includeDays, setIncludeDays] as const,
['includePromoGroup', includeGroup, setIncludeGroup] as const,
] as const
).map(([key, checked, setChecked]) => (
<label key={key} className="flex cursor-pointer items-center gap-3">
<button
type="button"
onClick={() => setChecked(!checked)}
role="switch"
aria-checked={checked}
aria-label={t(`admin.promocodes.form.${key}`)}
className={`relative h-6 w-10 rounded-full transition-colors ${
checked ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
checked ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="text-sm text-dark-200">{t(`admin.promocodes.form.${key}`)}</span>
</label>
))}
</div>
</div>
)}
{/* Type-specific fields */}
{type === 'balance' && (
{mode === 'bonus_set' && includeBalance && (
<div>
<label
htmlFor="pc-balance-bonus"
@@ -289,7 +361,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{(type === 'subscription_days' || type === 'trial_subscription') && (
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
<div>
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.daysCount')}
@@ -310,7 +382,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'trial_subscription' && (
{mode === 'trial_subscription' && (
<div>
<label htmlFor="pc-tariff" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.tariff', 'Тариф')}
@@ -345,7 +417,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'promo_group' && (
{mode === 'bonus_set' && includeGroup && (
<div>
<label
htmlFor="pc-promo-group"
@@ -370,7 +442,7 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'discount' && (
{mode === 'discount' && (
<>
<div>
<label

View File

@@ -2,7 +2,7 @@ import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCodeType } from '../api/promocodes';
import { promocodesApi, type PromoCodeType } from '../api/promocodes';
import { AdminBackButton } from '../components/admin';
import { StatCard } from '../components/stats';
import {
@@ -22,6 +22,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
discount: i18n.t('admin.promocodes.type.discount'),
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
};
return labels[type] || type;
};
@@ -33,6 +34,7 @@ const getTypeColor = (type: PromoCodeType): string => {
trial_subscription: 'bg-accent-500/20 text-accent-400',
promo_group: 'bg-warning-500/20 text-warning-400',
discount: 'bg-pink-500/20 text-pink-400',
balance_and_days: 'bg-success-500/20 text-success-400',
};
return colors[type] || 'bg-dark-600 text-dark-300';
};

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCode, PromoCodeType } from '../api/promocodes';
import { promocodesApi, type PromoCode, type PromoCodeType } from '../api/promocodes';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
import {
@@ -29,6 +29,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
discount: i18n.t('admin.promocodes.type.discount'),
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
};
return labels[type] || type;
};
@@ -40,6 +41,7 @@ const getTypeColor = (type: PromoCodeType): string => {
trial_subscription: 'bg-accent-500/20 text-accent-400',
promo_group: 'bg-warning-500/20 text-warning-400',
discount: 'bg-pink-500/20 text-pink-400',
balance_and_days: 'bg-success-500/20 text-success-400',
};
return colors[type] || 'bg-dark-600 text-dark-300';
};
@@ -194,13 +196,14 @@ export default function AdminPromocodes() {
</div>
{/* Info line */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
{promo.type === 'balance' && (
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
<span className="text-success-400">
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
</span>
)}
{(promo.type === 'subscription_days' ||
promo.type === 'trial_subscription') && (
promo.type === 'trial_subscription' ||
promo.type === 'balance_and_days') && (
<span className="text-accent-400">
+{promo.subscription_days} {t('admin.promocodes.form.days')}
</span>

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -335,11 +336,11 @@ export default function Balance() {
onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)}
>
<div className="font-semibold text-dark-100">
{translatedName || method.name}
{method.name || translatedName}
</div>
{(translatedDesc || method.description) && (
{(method.description || translatedDesc) && (
<div className="mt-1 text-sm text-dark-500">
{translatedDesc || method.description}
{method.description || translatedDesc}
</div>
)}
<div className="mt-3 text-xs text-dark-400">
@@ -413,7 +414,7 @@ export default function Balance() {
{getTypeLabel(tx.type)}
</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
{new Date(tx.created_at).toLocaleDateString(uiLocale())}
</span>
</div>
{tx.description && (

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate, useSearchParams, Link } from 'react-router';
@@ -74,7 +75,7 @@ function isGiftActivated(gift: SentGift): boolean {
function formatGiftDate(dateStr: string | null): string {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString(navigator.language || 'ru-RU', {
return date.toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',

View File

@@ -1,12 +1,13 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { PiCaretDown } from 'react-icons/pi';
import DOMPurify from 'dompurify';
import { infoApi, FaqPage, InfoVisibility } from '../api/info';
import { infoApi, type FaqPage, type InfoVisibility } from '../api/info';
import { formatContent } from '../utils/legalContent';
import { infoPagesApi } from '../api/infoPages';
import { promoApi, LoyaltyTierInfo } from '../api/promo';
import { promoApi, type LoyaltyTierInfo } from '../api/promo';
import type { FaqItem, ReplacesTab } from '../api/infoPages';
import { DocumentIcon, InfoIcon, QuestionIcon, ShieldIcon, StarIcon } from '@/components/icons';
@@ -489,7 +490,7 @@ export default function Info() {
/>
{rules.updated_at && (
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString(uiLocale())}
</p>
)}
</div>
@@ -517,7 +518,7 @@ export default function Info() {
/>
{privacy.updated_at && (
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString(uiLocale())}
</p>
)}
</div>
@@ -545,7 +546,7 @@ export default function Info() {
/>
{offer.updated_at && (
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString(uiLocale())}
</p>
)}
</div>
@@ -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,

View File

@@ -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 --

View File

@@ -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() {
<div className="flex items-center justify-between py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>
<span className="font-medium text-dark-100">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
{user?.created_at ? new Date(user.created_at).toLocaleDateString(uiLocale()) : '-'}
</span>
</div>
</div>

View File

@@ -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) {
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
{data.updated_at && (
<p className="mt-4 text-xs text-dark-500">
{t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
{t('info.updatedAt', 'Обновлено')}:{' '}
{new Date(data.updated_at).toLocaleDateString(uiLocale())}
</p>
)}
</div>

View File

@@ -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() {
</div>
<ScopeSelector
value={scope}
isFullNetwork={isFullNetwork}
onAdd={addScope}
onRemove={removeScope}
onClear={clearScope}
onFullNetworkChange={setFullNetwork}
className="min-w-0 flex-1 sm:max-w-xl"
/>
</div>

View File

@@ -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) {
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:gap-x-6 sm:gap-y-2">
<StatCard
label={t('admin.referralNetwork.stats.totalUsers')}
value={data.total_users.toLocaleString()}
value={data.total_users.toLocaleString(uiLocale())}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalReferrers')}
value={data.total_referrers.toLocaleString()}
value={data.total_referrers.toLocaleString(uiLocale())}
icon={<PartnerIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalCampaigns')}
value={data.total_campaigns.toLocaleString()}
value={data.total_campaigns.toLocaleString(uiLocale())}
icon={<CampaignIcon className="h-5 w-5" />}
tone="neutral"
/>

View File

@@ -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<ScopeType, string> = {
// '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<OptionType, string> = {
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<ScopeType, string> = {
const AVATAR_GLYPHS: Record<OptionType, React.ReactNode> = {
campaign: 'C',
partner: 'P',
user: 'U',
all: <ShareIcon className="h-4 w-4" />,
};
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
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`}
>
{selected ? <CheckIcon /> : AVATAR_LETTERS[type]}
{selected ? <CheckIcon /> : AVATAR_GLYPHS[type]}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">{title}</p>
@@ -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<ScopeType>('campaign');
const [searchInput, setSearchInput] = useState('');
@@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
{/* Chips row + add trigger */}
<div className="flex items-center gap-1.5">
{/* Selected chips */}
{value.length > 0 && (
{(isFullNetwork || value.length > 0) && (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{isFullNetwork && (
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${CHIP_COLORS.all}`}
>
<span className="max-w-[120px] truncate">
{t('admin.referralNetwork.scope.fullNetwork')}
</span>
<button
onClick={() => onFullNetworkChange(false)}
aria-label={t('admin.referralNetwork.scope.removeFullNetwork')}
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-white/10"
>
<CloseIcon className="h-3 w-3" />
</button>
</span>
)}
{value.map((item) => (
<span
key={`${item.type}:${item.id}`}
@@ -312,8 +344,18 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
</div>
</div>
{/* List */}
{/* List — the full-network option leads every tab, since it is a mode
rather than an entity of the currently active tab. */}
<div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true">
<div className="border-b border-dark-700/50">
<ScopeListItem
type="all"
selected={isFullNetwork}
onClick={() => onFullNetworkChange(!isFullNetwork)}
title={t('admin.referralNetwork.scope.fullNetwork')}
subtitle={t('admin.referralNetwork.scope.fullNetworkHint')}
/>
</div>
{activeTab === 'campaign' && renderCampaignList()}
{activeTab === 'partner' && renderPartnerList()}
{activeTab === 'user' && renderUserList()}

View File

@@ -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 && (
<p className="mt-0.5 text-xs text-dark-400">
{t('admin.referralNetwork.user.validUntil', {
date: new Date(user.subscription_end).toLocaleDateString(),
date: new Date(user.subscription_end).toLocaleDateString(uiLocale()),
})}
</p>
)}

View File

@@ -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())}`;
}
/**

View File

@@ -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;
}

View File

@@ -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() {
</div>
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
{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() {
/>
</div>
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
<span>
{new Date(purchase.created_at).toLocaleDateString(uiLocale())}
</span>
<span>
{new Date(purchase.expires_at).toLocaleDateString(uiLocale())}
</span>
</div>
</div>
))}
@@ -1203,7 +1208,7 @@ export default function Subscription() {
</div>
<div className="mt-1 text-[12px] text-dark-50/35">
{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 })})
</div>
</div>

View File

@@ -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() {
</span>
</div>
<div className="text-xs text-dark-500">
{new Date(ticket.updated_at).toLocaleDateString()}
{new Date(ticket.updated_at).toLocaleDateString(uiLocale())}
</div>
</button>
))}
@@ -558,7 +559,7 @@ export default function Support() {
</span>
<span className="text-xs text-dark-500">
{t('support.created')}{' '}
{new Date(selectedTicket.created_at).toLocaleDateString()}
{new Date(selectedTicket.created_at).toLocaleDateString(uiLocale())}
</span>
</div>
</div>
@@ -587,7 +588,7 @@ export default function Support() {
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
{new Date(msg.created_at).toLocaleString(uiLocale())}
</span>
</div>
{msg.message_text && (

View File

@@ -74,12 +74,12 @@ export default function TopUpMethodSelect() {
<div className="flex items-center gap-3">
<PaymentMethodIcon method={methodKey} className="h-8 w-8 flex-shrink-0" />
<div className="font-semibold text-dark-100">
{translatedName || method.name}
{method.name || translatedName}
</div>
</div>
{(translatedDesc || method.description) && (
{(method.description || translatedDesc) && (
<div className="mt-1 text-sm text-dark-500">
{translatedDesc || method.description}
{method.description || translatedDesc}
</div>
)}
<div className="mt-3 text-xs text-dark-400">

View File

@@ -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}
</div>
<div className="text-xs text-dark-500">
{new Date(item.created_at).toLocaleDateString()}
{new Date(item.created_at).toLocaleDateString(uiLocale())}
</div>
</div>
</div>

View File

@@ -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)]);
});
});

View File

@@ -24,12 +24,14 @@ interface ReferralNetworkState {
highlightedNodes: Set<string>;
filters: NetworkFilters;
scope: ScopeSelection[];
isFullNetwork: boolean;
setSelectedNode: (node: SelectedNode) => void;
setHoveredNode: (id: string | null) => void;
setHighlightedNodes: (nodes: Set<string>) => void;
updateFilters: (filters: Partial<NetworkFilters>) => 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<ReferralNetworkState>()((set) => (
highlightedNodes: new Set<string>(),
filters: { ...DEFAULT_FILTERS },
scope: [],
isFullNetwork: false,
setSelectedNode: (node) => set({ selectedNode: node }),
setHoveredNode: (id) => set({ hoveredNodeId: id }),
@@ -53,6 +56,16 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((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<ReferralNetworkState>()((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<ReferralNetworkState>()((set) => (
clearScope: () =>
set({
scope: [],
isFullNetwork: false,
...resetGraphState(),
}),
}));

View File

@@ -689,6 +689,7 @@ export interface PaymentMethodConfig {
is_enabled: boolean;
display_name: string | null;
default_display_name: string;
description: string | null;
sub_options: Record<string, boolean> | null;
available_sub_options: PaymentMethodSubOptionInfo[] | null;
quick_amounts: number[] | null;

View File

@@ -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<string, string> = {
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',

View File

@@ -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');
});
});

18
src/utils/uiLocale.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18next from 'i18next';
const LOCALE_MAP: Record<string, string> = {
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';
}