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