mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -10,8 +10,26 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
uses: ./.github/workflows/lint.yml
|
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:
|
build:
|
||||||
needs: lint
|
needs: [lint, test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -535,6 +535,15 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
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
|
// Update restrictions
|
||||||
updateRestrictions: async (
|
updateRestrictions: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type PromoCodeType =
|
|||||||
| 'subscription_days'
|
| 'subscription_days'
|
||||||
| 'trial_subscription'
|
| 'trial_subscription'
|
||||||
| 'promo_group'
|
| 'promo_group'
|
||||||
| 'discount';
|
| 'discount'
|
||||||
|
| 'balance_and_days';
|
||||||
|
|
||||||
export interface PromoCode {
|
export interface PromoCode {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const referralNetworkApi = {
|
|||||||
return response.data;
|
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> => {
|
getScopedGraph: async (selections: ScopeSelection[]): Promise<NetworkGraphData> => {
|
||||||
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
|
const campaignIds = selections.filter((s) => s.type === 'campaign').map((s) => s.id);
|
||||||
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);
|
const partnerIds = selections.filter((s) => s.type === 'partner').map((s) => s.id);
|
||||||
|
|||||||
@@ -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': {
|
case 'donut': {
|
||||||
const donutBgGradId = `${uid}-donut-bg`;
|
const donutBgGradId = `${uid}-donut-bg`;
|
||||||
const donutGlazeGradId = `${uid}-donut-glaze`;
|
const donutGlazeGradId = `${uid}-donut-glaze`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Shows prominent success messages for balance top-ups and subscription purchases.
|
* Shows prominent success messages for balance top-ups and subscription purchases.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -96,7 +97,7 @@ export default function SuccessNotificationModal() {
|
|||||||
|
|
||||||
// Format expiry date
|
// Format expiry date
|
||||||
const formattedExpiry = data.expiresAt
|
const formattedExpiry = data.expiresAt
|
||||||
? new Date(data.expiresAt).toLocaleDateString(undefined, {
|
? new Date(data.expiresAt).toLocaleDateString(uiLocale(), {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
|
|||||||
{ id: 'payments_etoplatezhi', categories: ['ETOPLATEZHI'] },
|
{ id: 'payments_etoplatezhi', categories: ['ETOPLATEZHI'] },
|
||||||
{ id: 'payments_antilopay', categories: ['ANTILOPAY'] },
|
{ id: 'payments_antilopay', categories: ['ANTILOPAY'] },
|
||||||
{ id: 'payments_jupiter', categories: ['JUPITER'] },
|
{ id: 'payments_jupiter', categories: ['JUPITER'] },
|
||||||
|
{ id: 'payments_cispay', categories: ['CISPAY'] },
|
||||||
{ id: 'payments_donut', categories: ['DONUT'] },
|
{ id: 'payments_donut', categories: ['DONUT'] },
|
||||||
{ id: 'payments_lava', categories: ['LAVA'] },
|
{ id: 'payments_lava', categories: ['LAVA'] },
|
||||||
{ id: 'payments_apple_iap', categories: ['APPLE_IAP'] },
|
{ id: 'payments_apple_iap', categories: ['APPLE_IAP'] },
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useNotify } from '../../../platform/hooks/useNotify';
|
||||||
import { useCurrency } from '../../../hooks/useCurrency';
|
import { useCurrency } from '../../../hooks/useCurrency';
|
||||||
import { createNumberInputHandler } from '../../../utils/inputHelpers';
|
import { createNumberInputHandler } from '../../../utils/inputHelpers';
|
||||||
import type {
|
import {
|
||||||
UserDetailResponse,
|
adminUsersApi,
|
||||||
UserListItem,
|
type UserDetailResponse,
|
||||||
UserPanelInfo,
|
type UserListItem,
|
||||||
UserSubscriptionInfo,
|
type UserPanelInfo,
|
||||||
|
type UserSubscriptionInfo,
|
||||||
} from '../../../api/adminUsers';
|
} from '../../../api/adminUsers';
|
||||||
import type { PromoGroup } from '../../../api/promocodes';
|
import type { PromoGroup } from '../../../api/promocodes';
|
||||||
import { ServerIcon } from '@/components/icons';
|
import { ServerIcon } from '@/components/icons';
|
||||||
@@ -92,6 +95,12 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
|
// «Отправить сообщение» — паритет с бот-кнопкой в карточке юзера
|
||||||
|
const [sendMsgOpen, setSendMsgOpen] = useState(false);
|
||||||
|
const [sendMsgText, setSendMsgText] = useState('');
|
||||||
|
const [sendMsgLoading, setSendMsgLoading] = useState(false);
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
@@ -124,6 +133,32 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
onFullDeleteUser,
|
onFullDeleteUser,
|
||||||
} = props;
|
} = 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
{t('admin.users.detail.actions.title')}
|
{t('admin.users.detail.actions.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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
|
<button
|
||||||
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
|
onClick={() => onInlineConfirm('resetTrial', onResetTrial)}
|
||||||
disabled={actionLoading}
|
disabled={actionLoading}
|
||||||
@@ -524,6 +569,55 @@ export function InfoTab(props: InfoTabProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Link } 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 TrafficProgressBar from './TrafficProgressBar';
|
||||||
import Sparkline from './Sparkline';
|
import Sparkline from './Sparkline';
|
||||||
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
||||||
@@ -48,7 +49,7 @@ export default function SubscriptionCardActive({
|
|||||||
const isAtDeviceLimit =
|
const isAtDeviceLimit =
|
||||||
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
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;
|
const daysLeft = subscription.days_left;
|
||||||
|
|
||||||
// Sparkline placeholder data (hidden until API provides daily usage)
|
// Sparkline placeholder data (hidden until API provides daily usage)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router';
|
import { Link, useNavigate, useLocation } from 'react-router';
|
||||||
@@ -37,7 +38,7 @@ export default function SubscriptionCardExpired({
|
|||||||
const [isRenewing, setIsRenewing] = useState(false);
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
const [renewError, setRenewError] = useState<string | null>(null);
|
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
|
// Detect limited (traffic exhausted) state
|
||||||
const isLimited = subscription.is_limited;
|
const isLimited = subscription.is_limited;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const METHOD_LABELS: Record<string, string> = {
|
|||||||
etoplatezhi: 'Etoplatezhi',
|
etoplatezhi: 'Etoplatezhi',
|
||||||
antilopay: 'Antilopay',
|
antilopay: 'Antilopay',
|
||||||
jupiter: 'Jupiter',
|
jupiter: 'Jupiter',
|
||||||
|
cispay: 'CisPay',
|
||||||
donut: 'Donut',
|
donut: 'Donut',
|
||||||
lava: 'Lava',
|
lava: 'Lava',
|
||||||
apple_iap: 'Apple In-App Purchase',
|
apple_iap: 'Apple In-App Purchase',
|
||||||
|
|||||||
@@ -238,6 +238,15 @@
|
|||||||
"failed": "Verification Failed",
|
"failed": "Verification Failed",
|
||||||
"goToLogin": "Go to Login"
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"welcome": "Welcome, {{name}}!",
|
"welcome": "Welcome, {{name}}!",
|
||||||
@@ -291,6 +300,7 @@
|
|||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"devices": "Devices",
|
"devices": "Devices",
|
||||||
"expiredDate": "Expired",
|
"expiredDate": "Expired",
|
||||||
|
"expiredDate_trial": "Expired",
|
||||||
"activeUntil": "Active until"
|
"activeUntil": "Active until"
|
||||||
},
|
},
|
||||||
"suspended": {
|
"suspended": {
|
||||||
@@ -746,6 +756,9 @@
|
|||||||
"jupiter": {
|
"jupiter": {
|
||||||
"description": "Pay via SBP through Jupiter (FPGate P2P)"
|
"description": "Pay via SBP through Jupiter (FPGate P2P)"
|
||||||
},
|
},
|
||||||
|
"cispay": {
|
||||||
|
"description": "Pay by card or via SBP (cisPay)"
|
||||||
|
},
|
||||||
"donut": {
|
"donut": {
|
||||||
"description": "Pay by card or SBP through Donut P2P"
|
"description": "Pay by card or SBP through Donut P2P"
|
||||||
},
|
},
|
||||||
@@ -1498,7 +1511,10 @@
|
|||||||
"removeItem": "Remove {{label}}",
|
"removeItem": "Remove {{label}}",
|
||||||
"clearAll": "Clear all selections",
|
"clearAll": "Clear all selections",
|
||||||
"addScope": "Add scope",
|
"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...",
|
"loading": "Loading graph...",
|
||||||
"error": "Failed to load network data",
|
"error": "Failed to load network data",
|
||||||
@@ -1611,7 +1627,7 @@
|
|||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "Payment Methods",
|
"title": "Payment Methods",
|
||||||
"description": "Configure display order and conditions",
|
"subtitle": "Configure display order and conditions",
|
||||||
"enabled": "On",
|
"enabled": "On",
|
||||||
"disabled": "Off",
|
"disabled": "Off",
|
||||||
"notConfigured": "Not configured",
|
"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.",
|
"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",
|
"displayName": "Display name",
|
||||||
"displayNameHint": "Leave empty for default name",
|
"displayNameHint": "Leave empty for default name",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionHint": "Shown under the method name. Empty for default text",
|
||||||
"subOptions": "Payment options",
|
"subOptions": "Payment options",
|
||||||
"minAmount": "Min amount (kopeks)",
|
"minAmount": "Min amount (kopeks)",
|
||||||
"maxAmount": "Max amount (kopeks)",
|
"maxAmount": "Max amount (kopeks)",
|
||||||
@@ -1693,6 +1711,7 @@
|
|||||||
"totalUpload": "Upload",
|
"totalUpload": "Upload",
|
||||||
"totalTraffic": "Total",
|
"totalTraffic": "Total",
|
||||||
"online": "online",
|
"online": "online",
|
||||||
|
"realtimeTitle": "Realtime traffic",
|
||||||
"inbounds": "Inbounds",
|
"inbounds": "Inbounds",
|
||||||
"outbounds": "Outbounds"
|
"outbounds": "Outbounds"
|
||||||
},
|
},
|
||||||
@@ -2175,6 +2194,7 @@
|
|||||||
"payments_etoplatezhi": "Etoplatezhi",
|
"payments_etoplatezhi": "Etoplatezhi",
|
||||||
"payments_antilopay": "Antilopay",
|
"payments_antilopay": "Antilopay",
|
||||||
"payments_jupiter": "Jupiter",
|
"payments_jupiter": "Jupiter",
|
||||||
|
"payments_cispay": "CisPay",
|
||||||
"payments_donut": "Donut",
|
"payments_donut": "Donut",
|
||||||
"payments_lava": "Lava",
|
"payments_lava": "Lava",
|
||||||
"payments_apple_iap": "Apple IAP",
|
"payments_apple_iap": "Apple IAP",
|
||||||
@@ -3133,7 +3153,8 @@
|
|||||||
"subscriptionDays": "Subscription days",
|
"subscriptionDays": "Subscription days",
|
||||||
"trialSubscription": "Trial subscription",
|
"trialSubscription": "Trial subscription",
|
||||||
"promoGroup": "Discount group",
|
"promoGroup": "Discount group",
|
||||||
"discount": "Discount %"
|
"discount": "Discount %",
|
||||||
|
"balanceAndDays": "Balance + days"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"editPromocode": "Edit promo code",
|
"editPromocode": "Edit promo code",
|
||||||
@@ -3151,6 +3172,11 @@
|
|||||||
"typeTrialSubscription": "Trial subscription",
|
"typeTrialSubscription": "Trial subscription",
|
||||||
"typePromoGroup": "Discount group",
|
"typePromoGroup": "Discount group",
|
||||||
"typeDiscount": "Percentage discount",
|
"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",
|
"bonusAmount": "Bonus amount",
|
||||||
"rub": "RUB",
|
"rub": "RUB",
|
||||||
"daysCount": "Number of days",
|
"daysCount": "Number of days",
|
||||||
@@ -3242,6 +3268,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"codeRequired": "Enter promo code",
|
"codeRequired": "Enter promo code",
|
||||||
|
"bonusSetEmpty": "Select at least one bonus for the promo code",
|
||||||
"balanceRequired": "Bonus amount must be greater than 0",
|
"balanceRequired": "Bonus amount must be greater than 0",
|
||||||
"daysRequired": "Number of days must be greater than 0",
|
"daysRequired": "Number of days must be greater than 0",
|
||||||
"groupRequired": "Select a discount group",
|
"groupRequired": "Select a discount group",
|
||||||
@@ -3342,6 +3369,20 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"block": "Block this user?"
|
"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": {
|
"userActions": {
|
||||||
"delete": "Delete user",
|
"delete": "Delete user",
|
||||||
"resetTrial": "Reset trial",
|
"resetTrial": "Reset trial",
|
||||||
@@ -5004,7 +5045,7 @@
|
|||||||
"test": "Test",
|
"test": "Test",
|
||||||
"activating": "Activating...",
|
"activating": "Activating...",
|
||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
"serverAccess": "Server access"
|
"serverAccess": "Access to {{count}} servers"
|
||||||
},
|
},
|
||||||
"deactivate": {
|
"deactivate": {
|
||||||
"button": "Cancel promo code",
|
"button": "Cancel promo code",
|
||||||
|
|||||||
@@ -633,6 +633,9 @@
|
|||||||
"jupiter": {
|
"jupiter": {
|
||||||
"description": "پرداخت SBP از طریق Jupiter (FPGate P2P)"
|
"description": "پرداخت SBP از طریق Jupiter (FPGate P2P)"
|
||||||
},
|
},
|
||||||
|
"cispay": {
|
||||||
|
"description": "پرداخت با کارت یا SBP از طریق cisPay"
|
||||||
|
},
|
||||||
"donut": {
|
"donut": {
|
||||||
"description": "پرداخت با کارت یا SBP از طریق Donut P2P"
|
"description": "پرداخت با کارت یا SBP از طریق Donut P2P"
|
||||||
},
|
},
|
||||||
@@ -1854,6 +1857,7 @@
|
|||||||
"payments_etoplatezhi": "Etoplatezhi",
|
"payments_etoplatezhi": "Etoplatezhi",
|
||||||
"payments_antilopay": "Antilopay",
|
"payments_antilopay": "Antilopay",
|
||||||
"payments_jupiter": "Jupiter",
|
"payments_jupiter": "Jupiter",
|
||||||
|
"payments_cispay": "CisPay",
|
||||||
"payments_donut": "Donut",
|
"payments_donut": "Donut",
|
||||||
"payments_lava": "Lava",
|
"payments_lava": "Lava",
|
||||||
"payments_apple_iap": "Apple IAP",
|
"payments_apple_iap": "Apple IAP",
|
||||||
@@ -2271,7 +2275,7 @@
|
|||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "روشهای پرداخت",
|
"title": "روشهای پرداخت",
|
||||||
"description": "پیکربندی ترتیب و شرایط نمایش",
|
"subtitle": "پیکربندی ترتیب و شرایط نمایش",
|
||||||
"enabled": "روشن",
|
"enabled": "روشن",
|
||||||
"disabled": "خاموش",
|
"disabled": "خاموش",
|
||||||
"notConfigured": "تنظیم نشده",
|
"notConfigured": "تنظیم نشده",
|
||||||
@@ -2287,6 +2291,8 @@
|
|||||||
"openUrlDirectHint": "روشن — لینک پرداخت بلافاصله باز میشود، بدون پنل دکمهٔ میانی. خاموش — دکمهٔ «باز کردن پرداخت» نمایش داده میشود. داخل تلگرام لینک در هر دو حالت در مرورگر خارجی باز میشود (در غیر این صورت پرداخت از طریق اپ بانکی/SBP کار نمیکند)؛ پس از پرداخت کاربر به صفحهٔ نتیجه بازمیگردد. Stars/CryptoBot (لینکهای t.me/) همیشه بهصورت نیتیو باز میشوند و این پرچم روی آنها اعمال نمیشود.",
|
"openUrlDirectHint": "روشن — لینک پرداخت بلافاصله باز میشود، بدون پنل دکمهٔ میانی. خاموش — دکمهٔ «باز کردن پرداخت» نمایش داده میشود. داخل تلگرام لینک در هر دو حالت در مرورگر خارجی باز میشود (در غیر این صورت پرداخت از طریق اپ بانکی/SBP کار نمیکند)؛ پس از پرداخت کاربر به صفحهٔ نتیجه بازمیگردد. Stars/CryptoBot (لینکهای t.me/) همیشه بهصورت نیتیو باز میشوند و این پرچم روی آنها اعمال نمیشود.",
|
||||||
"displayName": "نام نمایشی",
|
"displayName": "نام نمایشی",
|
||||||
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
||||||
|
"description": "توضیحات",
|
||||||
|
"descriptionHint": "زیر نام روش پرداخت نمایش داده میشود. خالی — متن پیشفرض",
|
||||||
"subOptions": "گزینههای پرداخت",
|
"subOptions": "گزینههای پرداخت",
|
||||||
"minAmount": "حداقل مبلغ (کپک)",
|
"minAmount": "حداقل مبلغ (کپک)",
|
||||||
"maxAmount": "حداکثر مبلغ (کپک)",
|
"maxAmount": "حداکثر مبلغ (کپک)",
|
||||||
@@ -2669,7 +2675,8 @@
|
|||||||
"subscriptionDays": "روزهای اشتراک",
|
"subscriptionDays": "روزهای اشتراک",
|
||||||
"trialSubscription": "اشتراک آزمایشی",
|
"trialSubscription": "اشتراک آزمایشی",
|
||||||
"promoGroup": "گروه تخفیف",
|
"promoGroup": "گروه تخفیف",
|
||||||
"discount": "تخفیف %"
|
"discount": "تخفیف %",
|
||||||
|
"balanceAndDays": "موجودی + روزها"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"editPromocode": "ویرایش کد تخفیف",
|
"editPromocode": "ویرایش کد تخفیف",
|
||||||
@@ -2687,6 +2694,11 @@
|
|||||||
"typeTrialSubscription": "اشتراک آزمایشی",
|
"typeTrialSubscription": "اشتراک آزمایشی",
|
||||||
"typePromoGroup": "گروه تخفیف",
|
"typePromoGroup": "گروه تخفیف",
|
||||||
"typeDiscount": "تخفیف درصدی",
|
"typeDiscount": "تخفیف درصدی",
|
||||||
|
"typeBonusSet": "مجموعه پاداش (موجودی / روزها / گروه تخفیف)",
|
||||||
|
"bonusComposition": "محتوای کد تخفیف",
|
||||||
|
"includeBalance": "پاداش موجودی",
|
||||||
|
"includeDays": "روزهای اشتراک",
|
||||||
|
"includePromoGroup": "گروه تخفیف",
|
||||||
"bonusAmount": "مبلغ پاداش",
|
"bonusAmount": "مبلغ پاداش",
|
||||||
"rub": "روبل",
|
"rub": "روبل",
|
||||||
"daysCount": "تعداد روزها",
|
"daysCount": "تعداد روزها",
|
||||||
@@ -2776,6 +2788,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"codeRequired": "کد تخفیف را وارد کنید",
|
"codeRequired": "کد تخفیف را وارد کنید",
|
||||||
|
"bonusSetEmpty": "حداقل یک پاداش برای کد تخفیف انتخاب کنید",
|
||||||
"balanceRequired": "مبلغ پاداش باید بیشتر از 0 باشد",
|
"balanceRequired": "مبلغ پاداش باید بیشتر از 0 باشد",
|
||||||
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
||||||
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
||||||
@@ -3153,6 +3166,20 @@
|
|||||||
},
|
},
|
||||||
"notFound": "کاربر یافت نشد",
|
"notFound": "کاربر یافت نشد",
|
||||||
"purchaseCount": "تعداد خرید: {{count}}",
|
"purchaseCount": "تعداد خرید: {{count}}",
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ ارسال پیام",
|
||||||
|
"title": "ارسال پیام به کاربر",
|
||||||
|
"placeholder": "متنی که ربات برای کاربر ارسال میکند (HTML پشتیبانی میشود)",
|
||||||
|
"send": "ارسال",
|
||||||
|
"sending": "در حال ارسال...",
|
||||||
|
"success": "پیام برای کاربر ارسال شد",
|
||||||
|
"noTelegram": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "این کاربر فقط با ایمیل ثبتنام کرده و نمیتواند پیام تلگرام دریافت کند",
|
||||||
|
"forbidden": "کاربر ربات را مسدود کرده یا نمیتواند پیام دریافت کند",
|
||||||
|
"bad_request": "تلگرام پیام را رد کرد. متن (نشانهگذاری HTML) را بررسی کرده و دوباره تلاش کنید"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "حذف کاربر",
|
"delete": "حذف کاربر",
|
||||||
"disable": "غیرفعالسازی",
|
"disable": "غیرفعالسازی",
|
||||||
|
|||||||
91
src/locales/locales.test.ts
Normal file
91
src/locales/locales.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -775,6 +775,9 @@
|
|||||||
"jupiter": {
|
"jupiter": {
|
||||||
"description": "Оплата СБП через Jupiter (FPGate P2P)"
|
"description": "Оплата СБП через Jupiter (FPGate P2P)"
|
||||||
},
|
},
|
||||||
|
"cispay": {
|
||||||
|
"description": "Оплата картой или через СБП (cisPay)"
|
||||||
|
},
|
||||||
"donut": {
|
"donut": {
|
||||||
"description": "Оплата картой и СБП через Donut P2P"
|
"description": "Оплата картой и СБП через Donut P2P"
|
||||||
},
|
},
|
||||||
@@ -1520,7 +1523,10 @@
|
|||||||
"removeItem": "Удалить {{label}}",
|
"removeItem": "Удалить {{label}}",
|
||||||
"clearAll": "Очистить все выбранные",
|
"clearAll": "Очистить все выбранные",
|
||||||
"addScope": "Добавить область",
|
"addScope": "Добавить область",
|
||||||
"maxReached": "Максимум {{max}} элементов"
|
"maxReached": "Максимум {{max}} элементов",
|
||||||
|
"fullNetwork": "Вся сеть",
|
||||||
|
"fullNetworkHint": "Показать граф целиком",
|
||||||
|
"removeFullNetwork": "Выключить режим полной сети"
|
||||||
},
|
},
|
||||||
"loading": "Загрузка графа...",
|
"loading": "Загрузка графа...",
|
||||||
"error": "Ошибка загрузки данных сети",
|
"error": "Ошибка загрузки данных сети",
|
||||||
@@ -1633,7 +1639,7 @@
|
|||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "Платёжные методы",
|
"title": "Платёжные методы",
|
||||||
"description": "Настройка порядка и условий отображения",
|
"subtitle": "Настройка порядка и условий отображения",
|
||||||
"enabled": "Вкл",
|
"enabled": "Вкл",
|
||||||
"disabled": "Выкл",
|
"disabled": "Выкл",
|
||||||
"notConfigured": "Не настроен",
|
"notConfigured": "Не настроен",
|
||||||
@@ -1649,6 +1655,8 @@
|
|||||||
"openUrlDirectHint": "ВКЛ — ссылка оплаты открывается сразу, без промежуточной панели с кнопкой. ВЫКЛ — показывается кнопка «Открыть оплату». В Telegram ссылка в обоих случаях открывается во внешнем браузере (иначе оплата через банковское приложение/СБП не срабатывает), после оплаты — возврат на страницу результата. Stars/CryptoBot (t.me/-ссылки) всегда открываются нативно, флаг к ним не применяется.",
|
"openUrlDirectHint": "ВКЛ — ссылка оплаты открывается сразу, без промежуточной панели с кнопкой. ВЫКЛ — показывается кнопка «Открыть оплату». В Telegram ссылка в обоих случаях открывается во внешнем браузере (иначе оплата через банковское приложение/СБП не срабатывает), после оплаты — возврат на страницу результата. Stars/CryptoBot (t.me/-ссылки) всегда открываются нативно, флаг к ним не применяется.",
|
||||||
"displayName": "Отображаемое имя",
|
"displayName": "Отображаемое имя",
|
||||||
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
||||||
|
"description": "Описание",
|
||||||
|
"descriptionHint": "Показывается под названием метода. Пусто — текст по умолчанию",
|
||||||
"subOptions": "Варианты оплаты",
|
"subOptions": "Варианты оплаты",
|
||||||
"minAmount": "Мин. сумма (коп.)",
|
"minAmount": "Мин. сумма (коп.)",
|
||||||
"maxAmount": "Макс. сумма (коп.)",
|
"maxAmount": "Макс. сумма (коп.)",
|
||||||
@@ -2193,6 +2201,7 @@
|
|||||||
"payments_etoplatezhi": "Etoplatezhi",
|
"payments_etoplatezhi": "Etoplatezhi",
|
||||||
"payments_antilopay": "Antilopay",
|
"payments_antilopay": "Antilopay",
|
||||||
"payments_jupiter": "Jupiter",
|
"payments_jupiter": "Jupiter",
|
||||||
|
"payments_cispay": "CisPay",
|
||||||
"payments_donut": "Donut",
|
"payments_donut": "Donut",
|
||||||
"payments_lava": "Lava",
|
"payments_lava": "Lava",
|
||||||
"payments_apple_iap": "Apple IAP",
|
"payments_apple_iap": "Apple IAP",
|
||||||
@@ -2924,6 +2933,7 @@
|
|||||||
"namePlaceholder": "Введите название тарифа",
|
"namePlaceholder": "Введите название тарифа",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"descriptionPlaceholder": "Краткое описание тарифа",
|
"descriptionPlaceholder": "Краткое описание тарифа",
|
||||||
|
"descriptionPlaceholder2": "Краткое описание тарифа",
|
||||||
"trafficLimit": "Лимит трафика",
|
"trafficLimit": "Лимит трафика",
|
||||||
"trafficHint": "0 = безлимитный трафик",
|
"trafficHint": "0 = безлимитный трафик",
|
||||||
"deviceLimit": "Лимит устройств",
|
"deviceLimit": "Лимит устройств",
|
||||||
@@ -3537,7 +3547,8 @@
|
|||||||
"subscriptionDays": "Дни подписки",
|
"subscriptionDays": "Дни подписки",
|
||||||
"trialSubscription": "Тестовая подписка",
|
"trialSubscription": "Тестовая подписка",
|
||||||
"promoGroup": "Группа скидок",
|
"promoGroup": "Группа скидок",
|
||||||
"discount": "Скидка %"
|
"discount": "Скидка %",
|
||||||
|
"balanceAndDays": "Баланс + дни"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"editPromocode": "Редактирование промокода",
|
"editPromocode": "Редактирование промокода",
|
||||||
@@ -3555,6 +3566,11 @@
|
|||||||
"typeTrialSubscription": "Тестовая подписка",
|
"typeTrialSubscription": "Тестовая подписка",
|
||||||
"typePromoGroup": "Группа скидок",
|
"typePromoGroup": "Группа скидок",
|
||||||
"typeDiscount": "Процентная скидка",
|
"typeDiscount": "Процентная скидка",
|
||||||
|
"typeBonusSet": "Набор бонусов (баланс / дни / промогруппа)",
|
||||||
|
"bonusComposition": "Состав промокода",
|
||||||
|
"includeBalance": "Бонус на баланс",
|
||||||
|
"includeDays": "Дни подписки",
|
||||||
|
"includePromoGroup": "Промогруппа",
|
||||||
"bonusAmount": "Сумма бонуса",
|
"bonusAmount": "Сумма бонуса",
|
||||||
"rub": "руб.",
|
"rub": "руб.",
|
||||||
"daysCount": "Количество дней",
|
"daysCount": "Количество дней",
|
||||||
@@ -3648,6 +3664,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"codeRequired": "Введите код промокода",
|
"codeRequired": "Введите код промокода",
|
||||||
|
"bonusSetEmpty": "Выберите хотя бы один бонус в составе промокода",
|
||||||
"balanceRequired": "Сумма бонуса должна быть больше 0",
|
"balanceRequired": "Сумма бонуса должна быть больше 0",
|
||||||
"daysRequired": "Количество дней должно быть больше 0",
|
"daysRequired": "Количество дней должно быть больше 0",
|
||||||
"groupRequired": "Выберите группу скидок",
|
"groupRequired": "Выберите группу скидок",
|
||||||
@@ -3749,6 +3766,20 @@
|
|||||||
"confirm": {
|
"confirm": {
|
||||||
"block": "Заблокировать пользователя?"
|
"block": "Заблокировать пользователя?"
|
||||||
},
|
},
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ Отправить сообщение",
|
||||||
|
"title": "Отправка сообщения пользователю",
|
||||||
|
"placeholder": "Текст, который бот отправит пользователю (поддерживается HTML)",
|
||||||
|
"send": "Отправить",
|
||||||
|
"sending": "Отправка...",
|
||||||
|
"success": "Сообщение отправлено пользователю",
|
||||||
|
"noTelegram": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "Пользователь зарегистрирован только по email и не может получать сообщения в Telegram",
|
||||||
|
"forbidden": "Пользователь заблокировал бота или не может получать сообщения",
|
||||||
|
"bad_request": "Telegram отклонил сообщение. Проверьте текст (HTML-разметку) и попробуйте ещё раз"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "Удалить пользователя",
|
"delete": "Удалить пользователя",
|
||||||
"resetTrial": "Сбросить триал",
|
"resetTrial": "Сбросить триал",
|
||||||
@@ -3814,6 +3845,7 @@
|
|||||||
"purchases": "Покупок",
|
"purchases": "Покупок",
|
||||||
"campaign": "Рекламная кампания",
|
"campaign": "Рекламная кампания",
|
||||||
"referralsList": "Список рефералов",
|
"referralsList": "Список рефералов",
|
||||||
|
"referralEarnings": "Реф. доход",
|
||||||
"noReferrals": "Нет рефералов",
|
"noReferrals": "Нет рефералов",
|
||||||
"panelConfig": "Конфигурация панели",
|
"panelConfig": "Конфигурация панели",
|
||||||
"trojanPassword": "Trojan пароль",
|
"trojanPassword": "Trojan пароль",
|
||||||
|
|||||||
@@ -633,6 +633,9 @@
|
|||||||
"jupiter": {
|
"jupiter": {
|
||||||
"description": "通过 Jupiter (FPGate P2P) 使用 SBP 支付"
|
"description": "通过 Jupiter (FPGate P2P) 使用 SBP 支付"
|
||||||
},
|
},
|
||||||
|
"cispay": {
|
||||||
|
"description": "通过 cisPay 使用银行卡或 SBP 支付"
|
||||||
|
},
|
||||||
"donut": {
|
"donut": {
|
||||||
"description": "通过 Donut P2P 使用银行卡或 SBP 支付"
|
"description": "通过 Donut P2P 使用银行卡或 SBP 支付"
|
||||||
},
|
},
|
||||||
@@ -1420,7 +1423,7 @@
|
|||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "支付方法",
|
"title": "支付方法",
|
||||||
"description": "配置顺序和显示条件",
|
"subtitle": "配置顺序和显示条件",
|
||||||
"enabled": "开启",
|
"enabled": "开启",
|
||||||
"disabled": "关闭",
|
"disabled": "关闭",
|
||||||
"notConfigured": "未配置",
|
"notConfigured": "未配置",
|
||||||
@@ -1436,6 +1439,8 @@
|
|||||||
"openUrlDirectHint": "开启 — 支付链接立即打开,不显示中间的按钮面板。关闭 — 显示「打开支付」按钮。在 Telegram 内两种情况都会在外部浏览器中打开链接(否则通过银行 App/SBP 付款会失败);支付完成后返回结果页。Stars/CryptoBot(t.me/ 链接)始终原生打开,不受此开关影响。",
|
"openUrlDirectHint": "开启 — 支付链接立即打开,不显示中间的按钮面板。关闭 — 显示「打开支付」按钮。在 Telegram 内两种情况都会在外部浏览器中打开链接(否则通过银行 App/SBP 付款会失败);支付完成后返回结果页。Stars/CryptoBot(t.me/ 链接)始终原生打开,不受此开关影响。",
|
||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"displayNameHint": "留空以使用默认名称",
|
"displayNameHint": "留空以使用默认名称",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionHint": "显示在支付方法名称下方。留空则使用默认文本",
|
||||||
"subOptions": "支付选项",
|
"subOptions": "支付选项",
|
||||||
"minAmount": "最小金额(戈比)",
|
"minAmount": "最小金额(戈比)",
|
||||||
"maxAmount": "最大金额(戈比)",
|
"maxAmount": "最大金额(戈比)",
|
||||||
@@ -1919,6 +1924,7 @@
|
|||||||
"payments_etoplatezhi": "Etoplatezhi",
|
"payments_etoplatezhi": "Etoplatezhi",
|
||||||
"payments_antilopay": "Antilopay",
|
"payments_antilopay": "Antilopay",
|
||||||
"payments_jupiter": "Jupiter",
|
"payments_jupiter": "Jupiter",
|
||||||
|
"payments_cispay": "CisPay",
|
||||||
"payments_donut": "Donut",
|
"payments_donut": "Donut",
|
||||||
"payments_lava": "Lava",
|
"payments_lava": "Lava",
|
||||||
"payments_apple_iap": "Apple IAP",
|
"payments_apple_iap": "Apple IAP",
|
||||||
@@ -2668,7 +2674,8 @@
|
|||||||
"subscriptionDays": "订阅天数",
|
"subscriptionDays": "订阅天数",
|
||||||
"trialSubscription": "试用订阅",
|
"trialSubscription": "试用订阅",
|
||||||
"promoGroup": "折扣组",
|
"promoGroup": "折扣组",
|
||||||
"discount": "折扣 %"
|
"discount": "折扣 %",
|
||||||
|
"balanceAndDays": "余额 + 天数"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"editPromocode": "编辑促销码",
|
"editPromocode": "编辑促销码",
|
||||||
@@ -2686,6 +2693,11 @@
|
|||||||
"typeTrialSubscription": "试用订阅",
|
"typeTrialSubscription": "试用订阅",
|
||||||
"typePromoGroup": "折扣组",
|
"typePromoGroup": "折扣组",
|
||||||
"typeDiscount": "百分比折扣",
|
"typeDiscount": "百分比折扣",
|
||||||
|
"typeBonusSet": "奖励组合(余额 / 天数 / 优惠组)",
|
||||||
|
"bonusComposition": "促销码内容",
|
||||||
|
"includeBalance": "余额奖励",
|
||||||
|
"includeDays": "订阅天数",
|
||||||
|
"includePromoGroup": "优惠组",
|
||||||
"bonusAmount": "奖金金额",
|
"bonusAmount": "奖金金额",
|
||||||
"rub": "卢布",
|
"rub": "卢布",
|
||||||
"daysCount": "天数",
|
"daysCount": "天数",
|
||||||
@@ -2775,6 +2787,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"codeRequired": "请输入促销码",
|
"codeRequired": "请输入促销码",
|
||||||
|
"bonusSetEmpty": "请为促销码至少选择一项奖励",
|
||||||
"balanceRequired": "奖金金额必须大于0",
|
"balanceRequired": "奖金金额必须大于0",
|
||||||
"daysRequired": "天数必须大于0",
|
"daysRequired": "天数必须大于0",
|
||||||
"groupRequired": "请选择折扣组",
|
"groupRequired": "请选择折扣组",
|
||||||
@@ -3152,6 +3165,20 @@
|
|||||||
},
|
},
|
||||||
"notFound": "未找到用户",
|
"notFound": "未找到用户",
|
||||||
"purchaseCount": "购买次数:{{count}}",
|
"purchaseCount": "购买次数:{{count}}",
|
||||||
|
"sendMessage": {
|
||||||
|
"button": "✉️ 发送消息",
|
||||||
|
"title": "向用户发送消息",
|
||||||
|
"placeholder": "机器人将发送给用户的文本(支持 HTML)",
|
||||||
|
"send": "发送",
|
||||||
|
"sending": "发送中...",
|
||||||
|
"success": "消息已发送给用户",
|
||||||
|
"noTelegram": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
|
||||||
|
"errors": {
|
||||||
|
"no_telegram_id": "该用户仅通过邮箱注册,无法接收 Telegram 消息",
|
||||||
|
"forbidden": "用户已屏蔽机器人或无法接收消息",
|
||||||
|
"bad_request": "Telegram 拒绝了该消息。请检查文本(HTML 标记)后重试"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userActions": {
|
"userActions": {
|
||||||
"delete": "删除用户",
|
"delete": "删除用户",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
// Local state for editing
|
// Local state for editing
|
||||||
const [isEnabled, setIsEnabled] = useState(false);
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
const [customName, setCustomName] = useState('');
|
const [customName, setCustomName] = useState('');
|
||||||
|
const [customDesc, setCustomDesc] = useState('');
|
||||||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
|
const [subOptions, setSubOptions] = useState<Record<string, boolean>>({});
|
||||||
const [minAmount, setMinAmount] = useState<number | ''>('');
|
const [minAmount, setMinAmount] = useState<number | ''>('');
|
||||||
const [maxAmount, setMaxAmount] = useState<number | ''>('');
|
const [maxAmount, setMaxAmount] = useState<number | ''>('');
|
||||||
@@ -249,6 +250,7 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
if (config) {
|
if (config) {
|
||||||
setIsEnabled(config.is_enabled);
|
setIsEnabled(config.is_enabled);
|
||||||
setCustomName(config.display_name || '');
|
setCustomName(config.display_name || '');
|
||||||
|
setCustomDesc(config.description || '');
|
||||||
setSubOptions(config.sub_options || {});
|
setSubOptions(config.sub_options || {});
|
||||||
setMinAmount(config.min_amount_kopeks ?? '');
|
setMinAmount(config.min_amount_kopeks ?? '');
|
||||||
setMaxAmount(config.max_amount_kopeks ?? '');
|
setMaxAmount(config.max_amount_kopeks ?? '');
|
||||||
@@ -290,6 +292,13 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
data.reset_display_name = true;
|
data.reset_display_name = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (customDesc.trim()) {
|
||||||
|
data.description = customDesc.trim();
|
||||||
|
} else {
|
||||||
|
data.reset_description = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Sub-options
|
// Sub-options
|
||||||
if (config.available_sub_options) {
|
if (config.available_sub_options) {
|
||||||
data.sub_options = subOptions;
|
data.sub_options = subOptions;
|
||||||
@@ -478,6 +487,20 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Sub-options */}
|
||||||
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default function AdminPaymentMethods() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.paymentMethods.title')}</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{orderChanged && (
|
{orderChanged && (
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { DateField } from '../components/DateField';
|
|||||||
import { createNumberInputHandler } from '../utils/inputHelpers';
|
import { createNumberInputHandler } from '../utils/inputHelpers';
|
||||||
import {
|
import {
|
||||||
promocodesApi,
|
promocodesApi,
|
||||||
PromoCodeDetail,
|
type PromoCodeDetail,
|
||||||
PromoCodeType,
|
type PromoCodeType,
|
||||||
PromoCodeCreateRequest,
|
type PromoCodeCreateRequest,
|
||||||
PromoCodeUpdateRequest,
|
type PromoCodeUpdateRequest,
|
||||||
PromoGroup,
|
type PromoGroup,
|
||||||
} from '../api/promocodes';
|
} from '../api/promocodes';
|
||||||
import { tariffsApi } from '../api/tariffs';
|
import { tariffsApi } from '../api/tariffs';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
@@ -34,9 +34,16 @@ export default function AdminPromocodeCreate() {
|
|||||||
const { capabilities } = usePlatform();
|
const { capabilities } = usePlatform();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
|
|
||||||
// Form state
|
// Form state.
|
||||||
|
// Режим формы: «набор бонусов» (баланс/дни/промогруппа чекбоксами — тип
|
||||||
|
// промокода выводится из выбранной комбинации) либо особые типы, которые не
|
||||||
|
// комбинируются: триал и процентная скидка (скидка переиспользует те же
|
||||||
|
// колонки с другой семантикой — процент/часы).
|
||||||
const [code, setCode] = useState('');
|
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 [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||||
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
|
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
|
||||||
const [maxUses, setMaxUses] = useState<number | ''>(1);
|
const [maxUses, setMaxUses] = useState<number | ''>(1);
|
||||||
@@ -58,7 +65,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
const { data: tariffsData } = useQuery({
|
const { data: tariffsData } = useQuery({
|
||||||
queryKey: ['admin-tariffs-for-promo'],
|
queryKey: ['admin-tariffs-for-promo'],
|
||||||
queryFn: () => tariffsApi.getTariffs(true),
|
queryFn: () => tariffsApi.getTariffs(true),
|
||||||
enabled: type === 'trial_subscription',
|
enabled: mode === 'trial_subscription',
|
||||||
});
|
});
|
||||||
|
|
||||||
const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null;
|
const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null;
|
||||||
@@ -74,7 +81,16 @@ export default function AdminPromocodeCreate() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
select: useCallback((data: PromoCodeDetail) => {
|
select: useCallback((data: PromoCodeDetail) => {
|
||||||
setCode(data.code);
|
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 discount type, balance_bonus_kopeks is percentage directly
|
||||||
// For balance type, balance_bonus_kopeks needs to be converted to rubles
|
// For balance type, balance_bonus_kopeks needs to be converted to rubles
|
||||||
if (data.type === 'discount') {
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
// For discount: balance_bonus_kopeks = percent (integer), subscription_days = hours
|
// For discount: balance_bonus_kopeks = percent (integer), subscription_days = hours
|
||||||
// For balance: balance_bonus_kopeks = rubles * 100
|
// For balance: balance_bonus_kopeks = rubles * 100
|
||||||
@@ -121,12 +151,19 @@ export default function AdminPromocodeCreate() {
|
|||||||
|
|
||||||
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
||||||
code: code.trim().toUpperCase(),
|
code: code.trim().toUpperCase(),
|
||||||
type,
|
type: derivedType,
|
||||||
balance_bonus_kopeks:
|
balance_bonus_kopeks:
|
||||||
type === 'discount'
|
mode === 'discount'
|
||||||
? Math.round(balanceValue) // percent as integer
|
? Math.round(balanceValue) // percent as integer
|
||||||
: Math.round(balanceValue * 100), // rubles to kopeks
|
: mode === 'bonus_set' && includeBalance
|
||||||
subscription_days: daysValue,
|
? Math.round(balanceValue * 100) // rubles to kopeks
|
||||||
|
: 0,
|
||||||
|
subscription_days:
|
||||||
|
mode === 'discount' ||
|
||||||
|
mode === 'trial_subscription' ||
|
||||||
|
(mode === 'bonus_set' && includeDays)
|
||||||
|
? daysValue
|
||||||
|
: 0,
|
||||||
max_uses: maxUsesValue,
|
max_uses: maxUsesValue,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
first_purchase_only: firstPurchaseOnly,
|
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
|
// (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.
|
// "today" already expired by 3am, surfacing as a bogus "expired" error.
|
||||||
valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null,
|
valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null,
|
||||||
promo_group_id: type === 'promo_group' ? promoGroupId : null,
|
promo_group_id: mode === 'bonus_set' && includeGroup ? promoGroupId : null,
|
||||||
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
|
...(mode === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -154,41 +191,41 @@ export default function AdminPromocodeCreate() {
|
|||||||
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
|
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
|
||||||
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
|
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
|
// Collect validation errors for display
|
||||||
const validationErrors: string[] = [];
|
const validationErrors: string[] = [];
|
||||||
if (!isCodeValid) {
|
if (!isCodeValid) {
|
||||||
validationErrors.push('codeRequired');
|
validationErrors.push('codeRequired');
|
||||||
}
|
}
|
||||||
if (type === 'balance' && balanceValue <= 0) {
|
if (mode === 'bonus_set') {
|
||||||
validationErrors.push('balanceRequired');
|
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');
|
validationErrors.push('daysRequired');
|
||||||
}
|
}
|
||||||
if (type === 'promo_group' && !promoGroupId) {
|
if (mode === 'discount') {
|
||||||
validationErrors.push('groupRequired');
|
|
||||||
}
|
|
||||||
if (type === 'discount') {
|
|
||||||
if (balanceValue <= 0 || balanceValue > 100) {
|
if (balanceValue <= 0 || balanceValue > 100) {
|
||||||
validationErrors.push('discountPercentInvalid');
|
validationErrors.push('discountPercentInvalid');
|
||||||
}
|
}
|
||||||
if (daysValue <= 0) {
|
// 0 часов = «бессрочно до первой покупки» — разрешено (как в isValid до
|
||||||
|
// унификации; раньше isValid и список ошибок противоречили друг другу)
|
||||||
|
if (daysValue < 0) {
|
||||||
validationErrors.push('discountHoursRequired');
|
validationErrors.push('discountHoursRequired');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValid = (): boolean => validationErrors.length === 0;
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isEdit && isLoadingPromocode) {
|
if (isEdit && isLoadingPromocode) {
|
||||||
return (
|
return (
|
||||||
@@ -247,24 +284,59 @@ export default function AdminPromocodeCreate() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="pc-type"
|
id="pc-type"
|
||||||
value={type}
|
value={mode}
|
||||||
onChange={(e) => setType(e.target.value as PromoCodeType)}
|
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="balance">{t('admin.promocodes.form.typeBalance')}</option>
|
<option value="bonus_set">{t('admin.promocodes.form.typeBonusSet')}</option>
|
||||||
<option value="subscription_days">
|
|
||||||
{t('admin.promocodes.form.typeSubscriptionDays')}
|
|
||||||
</option>
|
|
||||||
<option value="trial_subscription">
|
<option value="trial_subscription">
|
||||||
{t('admin.promocodes.form.typeTrialSubscription')}
|
{t('admin.promocodes.form.typeTrialSubscription')}
|
||||||
</option>
|
</option>
|
||||||
<option value="promo_group">{t('admin.promocodes.form.typePromoGroup')}</option>
|
|
||||||
<option value="discount">{t('admin.promocodes.form.typeDiscount')}</option>
|
<option value="discount">{t('admin.promocodes.form.typeDiscount')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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-specific fields */}
|
||||||
{type === 'balance' && (
|
{mode === 'bonus_set' && includeBalance && (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="pc-balance-bonus"
|
htmlFor="pc-balance-bonus"
|
||||||
@@ -289,7 +361,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(type === 'subscription_days' || type === 'trial_subscription') && (
|
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
|
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.promocodes.form.daysCount')}
|
{t('admin.promocodes.form.daysCount')}
|
||||||
@@ -310,7 +382,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'trial_subscription' && (
|
{mode === 'trial_subscription' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pc-tariff" className="mb-2 block text-sm font-medium text-dark-300">
|
<label htmlFor="pc-tariff" className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.promocodes.form.tariff', 'Тариф')}
|
{t('admin.promocodes.form.tariff', 'Тариф')}
|
||||||
@@ -345,7 +417,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'promo_group' && (
|
{mode === 'bonus_set' && includeGroup && (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="pc-promo-group"
|
htmlFor="pc-promo-group"
|
||||||
@@ -370,7 +442,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'discount' && (
|
{mode === 'discount' && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useParams, useNavigate } from 'react-router';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { promocodesApi, PromoCodeType } from '../api/promocodes';
|
import { promocodesApi, type PromoCodeType } from '../api/promocodes';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { StatCard } from '../components/stats';
|
import { StatCard } from '../components/stats';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
|
|||||||
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
|
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
|
||||||
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
|
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
|
||||||
discount: i18n.t('admin.promocodes.type.discount'),
|
discount: i18n.t('admin.promocodes.type.discount'),
|
||||||
|
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
};
|
};
|
||||||
@@ -33,6 +34,7 @@ const getTypeColor = (type: PromoCodeType): string => {
|
|||||||
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
||||||
promo_group: 'bg-warning-500/20 text-warning-400',
|
promo_group: 'bg-warning-500/20 text-warning-400',
|
||||||
discount: 'bg-pink-500/20 text-pink-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';
|
return colors[type] || 'bg-dark-600 text-dark-300';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
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 { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
|
|||||||
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
|
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
|
||||||
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
|
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
|
||||||
discount: i18n.t('admin.promocodes.type.discount'),
|
discount: i18n.t('admin.promocodes.type.discount'),
|
||||||
|
balance_and_days: i18n.t('admin.promocodes.type.balanceAndDays'),
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
};
|
};
|
||||||
@@ -40,6 +41,7 @@ const getTypeColor = (type: PromoCodeType): string => {
|
|||||||
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
trial_subscription: 'bg-accent-500/20 text-accent-400',
|
||||||
promo_group: 'bg-warning-500/20 text-warning-400',
|
promo_group: 'bg-warning-500/20 text-warning-400',
|
||||||
discount: 'bg-pink-500/20 text-pink-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';
|
return colors[type] || 'bg-dark-600 text-dark-300';
|
||||||
};
|
};
|
||||||
@@ -194,13 +196,14 @@ export default function AdminPromocodes() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Info line */}
|
{/* Info line */}
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
<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">
|
<span className="text-success-400">
|
||||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(promo.type === 'subscription_days' ||
|
{(promo.type === 'subscription_days' ||
|
||||||
promo.type === 'trial_subscription') && (
|
promo.type === 'trial_subscription' ||
|
||||||
|
promo.type === 'balance_and_days') && (
|
||||||
<span className="text-accent-400">
|
<span className="text-accent-400">
|
||||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -335,11 +336,11 @@ export default function Balance() {
|
|||||||
onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)}
|
onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-dark-100">
|
<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">
|
<div className="mt-1 text-sm text-dark-500">
|
||||||
{translatedDesc || method.description}
|
{method.description || translatedDesc}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 text-xs text-dark-400">
|
<div className="mt-3 text-xs text-dark-400">
|
||||||
@@ -413,7 +414,7 @@ export default function Balance() {
|
|||||||
{getTypeLabel(tx.type)}
|
{getTypeLabel(tx.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-dark-500">
|
<span className="text-xs text-dark-500">
|
||||||
{new Date(tx.created_at).toLocaleDateString()}
|
{new Date(tx.created_at).toLocaleDateString(uiLocale())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{tx.description && (
|
{tx.description && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router';
|
import { useNavigate, useSearchParams, Link } from 'react-router';
|
||||||
@@ -74,7 +75,7 @@ function isGiftActivated(gift: SentGift): boolean {
|
|||||||
function formatGiftDate(dateStr: string | null): string {
|
function formatGiftDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString(navigator.language || 'ru-RU', {
|
return date.toLocaleDateString(uiLocale(), {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiCaretDown } from 'react-icons/pi';
|
import { PiCaretDown } from 'react-icons/pi';
|
||||||
import DOMPurify from 'dompurify';
|
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 { formatContent } from '../utils/legalContent';
|
||||||
import { infoPagesApi } from '../api/infoPages';
|
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 type { FaqItem, ReplacesTab } from '../api/infoPages';
|
||||||
import { DocumentIcon, InfoIcon, QuestionIcon, ShieldIcon, StarIcon } from '@/components/icons';
|
import { DocumentIcon, InfoIcon, QuestionIcon, ShieldIcon, StarIcon } from '@/components/icons';
|
||||||
|
|
||||||
@@ -489,7 +490,7 @@ export default function Info() {
|
|||||||
/>
|
/>
|
||||||
{rules.updated_at && (
|
{rules.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -517,7 +518,7 @@ export default function Info() {
|
|||||||
/>
|
/>
|
||||||
{privacy.updated_at && (
|
{privacy.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -545,7 +546,7 @@ export default function Info() {
|
|||||||
/>
|
/>
|
||||||
{offer.updated_at && (
|
{offer.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -566,7 +567,7 @@ export default function Info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat(uiLocale(), {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router';
|
import { useParams, useNavigate, Link } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -88,7 +89,7 @@ function formatCountdown(seconds: number): string {
|
|||||||
function formatDate(dateStr: string | null): string {
|
function formatDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
return new Date(dateStr).toLocaleDateString(uiLocale(), {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -99,7 +100,7 @@ function formatDate(dateStr: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatBalance(kopeks: number): string {
|
function formatBalance(kopeks: number): string {
|
||||||
return Math.floor(kopeks / 100).toLocaleString();
|
return Math.floor(kopeks / 100).toLocaleString(uiLocale());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Radio Indicator --
|
// -- Radio Indicator --
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -303,7 +304,7 @@ export default function Profile() {
|
|||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||||
<span className="font-medium text-dark-100">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@@ -77,7 +78,8 @@ export default function PublicLegal({ doc }: PublicLegalProps) {
|
|||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
|
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
|
||||||
{data.updated_at && (
|
{data.updated_at && (
|
||||||
<p className="mt-4 text-xs text-dark-500">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,21 +17,26 @@ export function ReferralNetwork() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
|
const selectedNode = useReferralNetworkStore((s) => s.selectedNode);
|
||||||
const scope = useReferralNetworkStore((s) => s.scope);
|
const scope = useReferralNetworkStore((s) => s.scope);
|
||||||
|
const isFullNetwork = useReferralNetworkStore((s) => s.isFullNetwork);
|
||||||
const addScope = useReferralNetworkStore((s) => s.addScope);
|
const addScope = useReferralNetworkStore((s) => s.addScope);
|
||||||
const removeScope = useReferralNetworkStore((s) => s.removeScope);
|
const removeScope = useReferralNetworkStore((s) => s.removeScope);
|
||||||
const clearScope = useReferralNetworkStore((s) => s.clearScope);
|
const clearScope = useReferralNetworkStore((s) => s.clearScope);
|
||||||
|
const setFullNetwork = useReferralNetworkStore((s) => s.setFullNetwork);
|
||||||
|
|
||||||
const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight();
|
const { mobile: mobileHeaderHeight, bottomSafeArea } = useHeaderHeight();
|
||||||
|
|
||||||
const hasScope = scope.length > 0;
|
const hasScope = isFullNetwork || scope.length > 0;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: networkData,
|
data: networkData,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
|
queryKey: isFullNetwork
|
||||||
queryFn: () => referralNetworkApi.getScopedGraph(scope),
|
? ['referral-network', 'full']
|
||||||
|
: ['referral-network', 'scoped', scope.map((s) => `${s.type}:${s.id}`).sort()],
|
||||||
|
queryFn: () =>
|
||||||
|
isFullNetwork ? referralNetworkApi.getFullGraph() : referralNetworkApi.getScopedGraph(scope),
|
||||||
enabled: hasScope,
|
enabled: hasScope,
|
||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
});
|
});
|
||||||
@@ -60,9 +65,11 @@ export function ReferralNetwork() {
|
|||||||
</div>
|
</div>
|
||||||
<ScopeSelector
|
<ScopeSelector
|
||||||
value={scope}
|
value={scope}
|
||||||
|
isFullNetwork={isFullNetwork}
|
||||||
onAdd={addScope}
|
onAdd={addScope}
|
||||||
onRemove={removeScope}
|
onRemove={removeScope}
|
||||||
onClear={clearScope}
|
onClear={clearScope}
|
||||||
|
onFullNetworkChange={setFullNetwork}
|
||||||
className="min-w-0 flex-1 sm:max-w-xl"
|
className="min-w-0 flex-1 sm:max-w-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { StatCard } from '@/components/stats';
|
import { StatCard } from '@/components/stats';
|
||||||
import {
|
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">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:gap-x-6 sm:gap-y-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.referralNetwork.stats.totalUsers')}
|
label={t('admin.referralNetwork.stats.totalUsers')}
|
||||||
value={data.total_users.toLocaleString()}
|
value={data.total_users.toLocaleString(uiLocale())}
|
||||||
icon={<UsersIcon className="h-5 w-5" />}
|
icon={<UsersIcon className="h-5 w-5" />}
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.referralNetwork.stats.totalReferrers')}
|
label={t('admin.referralNetwork.stats.totalReferrers')}
|
||||||
value={data.total_referrers.toLocaleString()}
|
value={data.total_referrers.toLocaleString(uiLocale())}
|
||||||
icon={<PartnerIcon className="h-5 w-5" />}
|
icon={<PartnerIcon className="h-5 w-5" />}
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.referralNetwork.stats.totalCampaigns')}
|
label={t('admin.referralNetwork.stats.totalCampaigns')}
|
||||||
value={data.total_campaigns.toLocaleString()}
|
value={data.total_campaigns.toLocaleString(uiLocale())}
|
||||||
icon={<CampaignIcon className="h-5 w-5" />}
|
icon={<CampaignIcon className="h-5 w-5" />}
|
||||||
tone="neutral"
|
tone="neutral"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,33 +2,41 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
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 { MAX_SCOPE_ITEMS } from '@/store/referralNetwork';
|
||||||
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
|
import type { ScopeSelection, ScopeType } from '@/types/referralNetwork';
|
||||||
|
|
||||||
interface ScopeSelectorProps {
|
interface ScopeSelectorProps {
|
||||||
value: ScopeSelection[];
|
value: ScopeSelection[];
|
||||||
|
isFullNetwork: boolean;
|
||||||
onAdd: (selection: ScopeSelection) => void;
|
onAdd: (selection: ScopeSelection) => void;
|
||||||
onRemove: (type: ScopeSelection['type'], id: number) => void;
|
onRemove: (type: ScopeSelection['type'], id: number) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
onFullNetworkChange: (enabled: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCOPE_TABS: ScopeType[] = ['campaign', 'partner', 'user'];
|
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',
|
campaign: 'bg-success-500/20 text-success-400',
|
||||||
partner: 'bg-warning-500/20 text-warning-400',
|
partner: 'bg-warning-500/20 text-warning-400',
|
||||||
user: 'bg-accent-500/20 text-accent-400',
|
user: 'bg-accent-500/20 text-accent-400',
|
||||||
|
all: 'bg-dark-700 text-dark-100',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reuse CHIP_COLORS for avatar backgrounds (same palette)
|
// Reuse CHIP_COLORS for avatar backgrounds (same palette)
|
||||||
const AVATAR_COLORS = CHIP_COLORS;
|
const AVATAR_COLORS = CHIP_COLORS;
|
||||||
|
|
||||||
const AVATAR_LETTERS: Record<ScopeType, string> = {
|
const AVATAR_GLYPHS: Record<OptionType, React.ReactNode> = {
|
||||||
campaign: 'C',
|
campaign: 'C',
|
||||||
partner: 'P',
|
partner: 'P',
|
||||||
user: 'U',
|
user: 'U',
|
||||||
|
all: <ShareIcon className="h-4 w-4" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
|
function Spinner({ size = 'h-5 w-5' }: { size?: string }) {
|
||||||
@@ -44,7 +52,7 @@ function EmptyMessage({ text }: { text: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScopeListItemProps {
|
interface ScopeListItemProps {
|
||||||
type: ScopeType;
|
type: OptionType;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -65,7 +73,7 @@ function ScopeListItem({ type, selected, onClick, title, subtitle, badge }: Scop
|
|||||||
<div
|
<div
|
||||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium ${AVATAR_COLORS[type]}`}
|
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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-dark-100">{title}</p>
|
<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 { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
|
const [activeTab, setActiveTab] = useState<ScopeType>('campaign');
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
@@ -211,8 +227,24 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
|
|||||||
{/* Chips row + add trigger */}
|
{/* Chips row + add trigger */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Selected chips */}
|
{/* Selected chips */}
|
||||||
{value.length > 0 && (
|
{(isFullNetwork || value.length > 0) && (
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
<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) => (
|
{value.map((item) => (
|
||||||
<span
|
<span
|
||||||
key={`${item.type}:${item.id}`}
|
key={`${item.type}:${item.id}`}
|
||||||
@@ -312,8 +344,18 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
|
|||||||
</div>
|
</div>
|
||||||
</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="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 === 'campaign' && renderCampaignList()}
|
||||||
{activeTab === 'partner' && renderPartnerList()}
|
{activeTab === 'partner' && renderPartnerList()}
|
||||||
{activeTab === 'user' && renderUserList()}
|
{activeTab === 'user' && renderUserList()}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { referralNetworkApi } from '@/api/referralNetwork';
|
import { referralNetworkApi } from '@/api/referralNetwork';
|
||||||
@@ -115,7 +116,7 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
|
|||||||
{user.subscription_end && (
|
{user.subscription_end && (
|
||||||
<p className="mt-0.5 text-xs text-dark-400">
|
<p className="mt-0.5 text-xs text-dark-400">
|
||||||
{t('admin.referralNetwork.user.validUntil', {
|
{t('admin.referralNetwork.user.validUntil', {
|
||||||
date: new Date(user.subscription_end).toLocaleDateString(),
|
date: new Date(user.subscription_end).toLocaleDateString(uiLocale()),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import type { SubscriptionStatus } from '@/types/referralNetwork';
|
import type { SubscriptionStatus } from '@/types/referralNetwork';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format kopeks to a human-readable ruble string.
|
* Format kopeks to a human-readable ruble string.
|
||||||
*/
|
*/
|
||||||
export function formatKopeksToRubles(kopeks: number): string {
|
export function formatKopeksToRubles(kopeks: number): string {
|
||||||
return `${(kopeks / 100).toLocaleString('ru-RU')}`;
|
return `${(kopeks / 100).toLocaleString(uiLocale())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -17,7 +18,7 @@ function formatCardDate(dateStr: string): string {
|
|||||||
try {
|
try {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (isNaN(date.getTime())) return dateStr;
|
if (isNaN(date.getTime())) return dateStr;
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString(uiLocale());
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -73,7 +74,7 @@ const CountdownTimer = memo(function CountdownTimer({
|
|||||||
const isExpired = !isActive;
|
const isExpired = !isActive;
|
||||||
const isUrgent = countdown.days <= 3;
|
const isUrgent = countdown.days <= 3;
|
||||||
|
|
||||||
const formattedDate = new Date(endDate).toLocaleDateString(undefined, {
|
const formattedDate = new Date(endDate).toLocaleDateString(uiLocale(), {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -997,7 +998,7 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
|
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
|
||||||
{t('subscription.trafficResetAt')}:{' '}
|
{t('subscription.trafficResetAt')}:{' '}
|
||||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
{new Date(purchase.expires_at).toLocaleDateString(uiLocale(), {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -1017,8 +1018,12 @@ export default function Subscription() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
|
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
|
||||||
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
|
<span>
|
||||||
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
|
{new Date(purchase.created_at).toLocaleDateString(uiLocale())}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Date(purchase.expires_at).toLocaleDateString(uiLocale())}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1203,7 +1208,7 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-dark-50/35">
|
<div className="mt-1 text-[12px] text-dark-50/35">
|
||||||
{t('subscription.pause.pausedDescription')}{' '}
|
{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 })})
|
{t('subscription.pause.days', { count: subscription.days_left })})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -411,7 +412,7 @@ export default function Support() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{new Date(ticket.updated_at).toLocaleDateString()}
|
{new Date(ticket.updated_at).toLocaleDateString(uiLocale())}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -558,7 +559,7 @@ export default function Support() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-dark-500">
|
<span className="text-xs text-dark-500">
|
||||||
{t('support.created')}{' '}
|
{t('support.created')}{' '}
|
||||||
{new Date(selectedTicket.created_at).toLocaleDateString()}
|
{new Date(selectedTicket.created_at).toLocaleDateString(uiLocale())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -587,7 +588,7 @@ export default function Support() {
|
|||||||
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
|
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-dark-500">
|
<span className="text-xs text-dark-500">
|
||||||
{new Date(msg.created_at).toLocaleString()}
|
{new Date(msg.created_at).toLocaleString(uiLocale())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{msg.message_text && (
|
{msg.message_text && (
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ export default function TopUpMethodSelect() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<PaymentMethodIcon method={methodKey} className="h-8 w-8 flex-shrink-0" />
|
<PaymentMethodIcon method={methodKey} className="h-8 w-8 flex-shrink-0" />
|
||||||
<div className="font-semibold text-dark-100">
|
<div className="font-semibold text-dark-100">
|
||||||
{translatedName || method.name}
|
{method.name || translatedName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(translatedDesc || method.description) && (
|
{(method.description || translatedDesc) && (
|
||||||
<div className="mt-1 text-sm text-dark-500">
|
<div className="mt-1 text-sm text-dark-500">
|
||||||
{translatedDesc || method.description}
|
{method.description || translatedDesc}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 text-xs text-dark-400">
|
<div className="mt-3 text-xs text-dark-400">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uiLocale } from '@/utils/uiLocale';
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -798,7 +799,7 @@ export default function Wheel() {
|
|||||||
{item.prize_display_name}
|
{item.prize_display_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{new Date(item.created_at).toLocaleDateString()}
|
{new Date(item.created_at).toLocaleDateString(uiLocale())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
105
src/store/referralNetwork.test.ts
Normal file
105
src/store/referralNetwork.test.ts
Normal 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)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,12 +24,14 @@ interface ReferralNetworkState {
|
|||||||
highlightedNodes: Set<string>;
|
highlightedNodes: Set<string>;
|
||||||
filters: NetworkFilters;
|
filters: NetworkFilters;
|
||||||
scope: ScopeSelection[];
|
scope: ScopeSelection[];
|
||||||
|
isFullNetwork: boolean;
|
||||||
|
|
||||||
setSelectedNode: (node: SelectedNode) => void;
|
setSelectedNode: (node: SelectedNode) => void;
|
||||||
setHoveredNode: (id: string | null) => void;
|
setHoveredNode: (id: string | null) => void;
|
||||||
setHighlightedNodes: (nodes: Set<string>) => void;
|
setHighlightedNodes: (nodes: Set<string>) => void;
|
||||||
updateFilters: (filters: Partial<NetworkFilters>) => void;
|
updateFilters: (filters: Partial<NetworkFilters>) => void;
|
||||||
resetFilters: () => void;
|
resetFilters: () => void;
|
||||||
|
setFullNetwork: (enabled: boolean) => void;
|
||||||
addScope: (item: ScopeSelection) => void;
|
addScope: (item: ScopeSelection) => void;
|
||||||
removeScope: (type: ScopeSelection['type'], id: number) => void;
|
removeScope: (type: ScopeSelection['type'], id: number) => void;
|
||||||
clearScope: () => void;
|
clearScope: () => void;
|
||||||
@@ -41,6 +43,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
|
|||||||
highlightedNodes: new Set<string>(),
|
highlightedNodes: new Set<string>(),
|
||||||
filters: { ...DEFAULT_FILTERS },
|
filters: { ...DEFAULT_FILTERS },
|
||||||
scope: [],
|
scope: [],
|
||||||
|
isFullNetwork: false,
|
||||||
|
|
||||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||||
setHoveredNode: (id) => set({ hoveredNodeId: id }),
|
setHoveredNode: (id) => set({ hoveredNodeId: id }),
|
||||||
@@ -53,6 +56,16 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
|
|||||||
|
|
||||||
resetFilters: () => set({ filters: { ...DEFAULT_FILTERS } }),
|
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) =>
|
addScope: (item) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const exists = state.scope.some((s) => s.type === item.type && s.id === item.id);
|
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;
|
if (state.scope.length >= MAX_SCOPE_ITEMS) return state;
|
||||||
return {
|
return {
|
||||||
scope: [...state.scope, item],
|
scope: [...state.scope, item],
|
||||||
|
isFullNetwork: false,
|
||||||
...resetGraphState(),
|
...resetGraphState(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -76,6 +90,7 @@ export const useReferralNetworkStore = create<ReferralNetworkState>()((set) => (
|
|||||||
clearScope: () =>
|
clearScope: () =>
|
||||||
set({
|
set({
|
||||||
scope: [],
|
scope: [],
|
||||||
|
isFullNetwork: false,
|
||||||
...resetGraphState(),
|
...resetGraphState(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ export interface PaymentMethodConfig {
|
|||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
default_display_name: string;
|
default_display_name: string;
|
||||||
|
description: string | null;
|
||||||
sub_options: Record<string, boolean> | null;
|
sub_options: Record<string, boolean> | null;
|
||||||
available_sub_options: PaymentMethodSubOptionInfo[] | null;
|
available_sub_options: PaymentMethodSubOptionInfo[] | null;
|
||||||
quick_amounts: number[] | null;
|
quick_amounts: number[] | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function formatUptime(seconds: number): string {
|
|||||||
|
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { currencyApi, type ExchangeRates } from '../api/currency';
|
import { currencyApi, type ExchangeRates } from '../api/currency';
|
||||||
|
import { uiLocale } from './uiLocale';
|
||||||
|
|
||||||
const LANG_CURRENCY_MAP: Record<
|
const LANG_CURRENCY_MAP: Record<
|
||||||
string,
|
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. */
|
/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
|
||||||
export function formatShortDate(date: string | null): string {
|
export function formatShortDate(date: string | null): string {
|
||||||
if (!date) return '-';
|
if (!date) return '-';
|
||||||
const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
|
return new Date(date).toLocaleDateString(uiLocale(), {
|
||||||
return new Date(date).toLocaleDateString(locale, {
|
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
36
src/utils/uiLocale.test.ts
Normal file
36
src/utils/uiLocale.test.ts
Normal 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
18
src/utils/uiLocale.ts
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user