From dd50d648d069bb69db54d18d8e3bdf6bec965872 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:37:07 +0300 Subject: [PATCH 01/10] =?UTF-8?q?fix(i18n):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D0=B0=D0=BD=D0=B3=D0=BB=D0=B8?= =?UTF-8?q?=D0=B9=D1=81=D0=BA=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20resetPassword=20=D0=B8=20expiredDate=5Ftri?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/locales/en.json b/src/locales/en.json index 160c585..7683adf 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -238,6 +238,15 @@ "failed": "Verification Failed", "goToLogin": "Go to Login" }, + "resetPassword": { + "title": "Set new password", + "enterNewPassword": "Enter your new password below.", + "success": "Password changed!", + "redirectingToLogin": "Redirecting to login...", + "setPassword": "Set new password", + "invalidToken": "Invalid reset link", + "tokenExpiredOrInvalid": "This password reset link is invalid or has expired." + }, "dashboard": { "title": "Dashboard", "welcome": "Welcome, {{name}}!", @@ -291,6 +300,7 @@ "traffic": "Traffic", "devices": "Devices", "expiredDate": "Expired", + "expiredDate_trial": "Expired", "activeUntil": "Active until" }, "suspended": { @@ -5004,7 +5014,7 @@ "test": "Test", "activating": "Activating...", "activate": "Activate", - "serverAccess": "Server access" + "serverAccess": "Access to {{count}} servers" }, "deactivate": { "button": "Cancel promo code", From 81fd96c2a758d8d092289dbaefa105b29f54137d Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:04:45 +0300 Subject: [PATCH 02/10] =?UTF-8?q?fix(i18n):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8B=20=D0=B8=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D1=8E=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=8F=D0=B7=D1=8B?= =?UTF-8?q?=D0=BA=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SuccessNotificationModal.tsx | 3 +- .../dashboard/SubscriptionCardActive.tsx | 5 +-- .../dashboard/SubscriptionCardExpired.tsx | 3 +- src/pages/Balance.tsx | 3 +- src/pages/GiftSubscription.tsx | 3 +- src/pages/Info.tsx | 13 +++---- src/pages/MergeAccounts.tsx | 5 +-- src/pages/Profile.tsx | 3 +- src/pages/PublicLegal.tsx | 4 ++- .../components/NetworkStats.tsx | 7 ++-- .../components/UserDetailPanel.tsx | 3 +- src/pages/ReferralNetwork/utils.ts | 3 +- src/pages/SavedCards.tsx | 3 +- src/pages/Subscription.tsx | 15 +++++--- src/pages/Support.tsx | 7 ++-- src/pages/Wheel.tsx | 3 +- src/utils/format.ts | 11 ++---- src/utils/uiLocale.test.ts | 36 +++++++++++++++++++ src/utils/uiLocale.ts | 18 ++++++++++ 19 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 src/utils/uiLocale.test.ts create mode 100644 src/utils/uiLocale.ts diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 31cc36d..cf71da0 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -3,6 +3,7 @@ * Shows prominent success messages for balance top-ups and subscription purchases. */ +import { uiLocale } from '@/utils/uiLocale'; import { useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -96,7 +97,7 @@ export default function SuccessNotificationModal() { // Format expiry date const formattedExpiry = data.expiresAt - ? new Date(data.expiresAt).toLocaleDateString(undefined, { + ? new Date(data.expiresAt).toLocaleDateString(uiLocale(), { day: 'numeric', month: 'long', year: 'numeric', diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index e78d044..f79ee3a 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -1,7 +1,8 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { Link } from 'react-router'; -import { UseMutationResult } from '@tanstack/react-query'; +import type { UseMutationResult } from '@tanstack/react-query'; import TrafficProgressBar from './TrafficProgressBar'; import Sparkline from './Sparkline'; import { useAnimatedNumber } from '../../hooks/useAnimatedNumber'; @@ -48,7 +49,7 @@ export default function SubscriptionCardActive({ const isAtDeviceLimit = subscription.device_limit > 0 && connectedDevices >= subscription.device_limit; - const formattedDate = new Date(subscription.end_date).toLocaleDateString(); + const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale()); const daysLeft = subscription.days_left; // Sparkline placeholder data (hidden until API provides daily usage) diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 5431944..f1c83bb 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useLocation } from 'react-router'; @@ -37,7 +38,7 @@ export default function SubscriptionCardExpired({ const [isRenewing, setIsRenewing] = useState(false); const [renewError, setRenewError] = useState(null); - const formattedDate = new Date(subscription.end_date).toLocaleDateString(); + const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale()); // Detect limited (traffic exhausted) state const isLimited = subscription.is_limited; diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 76496b9..b277371 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -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'; @@ -413,7 +414,7 @@ export default function Balance() { {getTypeLabel(tx.type)} - {new Date(tx.created_at).toLocaleDateString()} + {new Date(tx.created_at).toLocaleDateString(uiLocale())} {tx.description && ( diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 57ff132..0bbbaeb 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -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', diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index adc4985..ffcee49 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -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 && (

- {t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()} + {t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString(uiLocale())}

)} @@ -517,7 +518,7 @@ export default function Info() { /> {privacy.updated_at && (

- {t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()} + {t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString(uiLocale())}

)} @@ -545,7 +546,7 @@ export default function Info() { /> {offer.updated_at && (

- {t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()} + {t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString(uiLocale())}

)} @@ -566,7 +567,7 @@ export default function Info() { } const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ru-RU', { + return new Intl.NumberFormat(uiLocale(), { style: 'currency', currency: 'RUB', minimumFractionDigits: 0, diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx index 846a5ff..03f808b 100644 --- a/src/pages/MergeAccounts.tsx +++ b/src/pages/MergeAccounts.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router'; import { useTranslation } from 'react-i18next'; @@ -88,7 +89,7 @@ function formatCountdown(seconds: number): string { function formatDate(dateStr: string | null): string { if (!dateStr) return '-'; try { - return new Date(dateStr).toLocaleDateString(undefined, { + return new Date(dateStr).toLocaleDateString(uiLocale(), { day: 'numeric', month: 'long', year: 'numeric', @@ -99,7 +100,7 @@ function formatDate(dateStr: string | null): string { } function formatBalance(kopeks: number): string { - return Math.floor(kopeks / 100).toLocaleString(); + return Math.floor(kopeks / 100).toLocaleString(uiLocale()); } // -- Radio Indicator -- diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index bbd387e..f7c9d71 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState, useRef, useEffect } from 'react'; import { Link, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; @@ -303,7 +304,7 @@ export default function Profile() {
{t('profile.registeredAt')} - {user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'} + {user?.created_at ? new Date(user.created_at).toLocaleDateString(uiLocale()) : '-'}
diff --git a/src/pages/PublicLegal.tsx b/src/pages/PublicLegal.tsx index 20b8a46..f839ecf 100644 --- a/src/pages/PublicLegal.tsx +++ b/src/pages/PublicLegal.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; @@ -77,7 +78,8 @@ export default function PublicLegal({ doc }: PublicLegalProps) {
{data.updated_at && (

- {t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()} + {t('info.updatedAt', 'Обновлено')}:{' '} + {new Date(data.updated_at).toLocaleDateString(uiLocale())}

)}
diff --git a/src/pages/ReferralNetwork/components/NetworkStats.tsx b/src/pages/ReferralNetwork/components/NetworkStats.tsx index b95ef94..f03225f 100644 --- a/src/pages/ReferralNetwork/components/NetworkStats.tsx +++ b/src/pages/ReferralNetwork/components/NetworkStats.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useTranslation } from 'react-i18next'; import { StatCard } from '@/components/stats'; import { @@ -25,19 +26,19 @@ export function NetworkStats({ data, className }: NetworkStatsProps) {
} tone="neutral" /> } tone="neutral" /> } tone="neutral" /> diff --git a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx index f17be20..628c1cc 100644 --- a/src/pages/ReferralNetwork/components/UserDetailPanel.tsx +++ b/src/pages/ReferralNetwork/components/UserDetailPanel.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; @@ -115,7 +116,7 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) { {user.subscription_end && (

{t('admin.referralNetwork.user.validUntil', { - date: new Date(user.subscription_end).toLocaleDateString(), + date: new Date(user.subscription_end).toLocaleDateString(uiLocale()), })}

)} diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index c7b5e1d..0b7ee14 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -1,10 +1,11 @@ +import { uiLocale } from '@/utils/uiLocale'; import type { SubscriptionStatus } from '@/types/referralNetwork'; /** * Format kopeks to a human-readable ruble string. */ export function formatKopeksToRubles(kopeks: number): string { - return `${(kopeks / 100).toLocaleString('ru-RU')}`; + return `${(kopeks / 100).toLocaleString(uiLocale())}`; } /** diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index 052030b..6501df0 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -17,7 +18,7 @@ function formatCardDate(dateStr: string): string { try { const date = new Date(dateStr); if (isNaN(date.getTime())) return dateStr; - return date.toLocaleDateString(); + return date.toLocaleDateString(uiLocale()); } catch { return dateStr; } diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 2392f8e..21cba8b 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -73,7 +74,7 @@ const CountdownTimer = memo(function CountdownTimer({ const isExpired = !isActive; const isUrgent = countdown.days <= 3; - const formattedDate = new Date(endDate).toLocaleDateString(undefined, { + const formattedDate = new Date(endDate).toLocaleDateString(uiLocale(), { day: 'numeric', month: 'short', year: 'numeric', @@ -997,7 +998,7 @@ export default function Subscription() {
{t('subscription.trafficResetAt')}:{' '} - {new Date(purchase.expires_at).toLocaleDateString(undefined, { + {new Date(purchase.expires_at).toLocaleDateString(uiLocale(), { day: '2-digit', month: '2-digit', year: 'numeric', @@ -1017,8 +1018,12 @@ export default function Subscription() { />
- {new Date(purchase.created_at).toLocaleDateString()} - {new Date(purchase.expires_at).toLocaleDateString()} + + {new Date(purchase.created_at).toLocaleDateString(uiLocale())} + + + {new Date(purchase.expires_at).toLocaleDateString(uiLocale())} +
))} @@ -1203,7 +1208,7 @@ export default function Subscription() {
{t('subscription.pause.pausedDescription')}{' '} - {new Date(subscription.end_date).toLocaleDateString()} ( + {new Date(subscription.end_date).toLocaleDateString(uiLocale())} ( {t('subscription.pause.days', { count: subscription.days_left })})
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 048bda8..4bda888 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -411,7 +412,7 @@ export default function Support() {
- {new Date(ticket.updated_at).toLocaleDateString()} + {new Date(ticket.updated_at).toLocaleDateString(uiLocale())}
))} @@ -558,7 +559,7 @@ export default function Support() { {t('support.created')}{' '} - {new Date(selectedTicket.created_at).toLocaleDateString()} + {new Date(selectedTicket.created_at).toLocaleDateString(uiLocale())} @@ -587,7 +588,7 @@ export default function Support() { {msg.is_from_admin ? t('support.supportTeam') : t('support.you')} - {new Date(msg.created_at).toLocaleString()} + {new Date(msg.created_at).toLocaleString(uiLocale())} {msg.message_text && ( diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 074ea3a..313c0f5 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -1,3 +1,4 @@ +import { uiLocale } from '@/utils/uiLocale'; import { useState, useCallback, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -798,7 +799,7 @@ export default function Wheel() { {item.prize_display_name}
- {new Date(item.created_at).toLocaleDateString()} + {new Date(item.created_at).toLocaleDateString(uiLocale())}
diff --git a/src/utils/format.ts b/src/utils/format.ts index 796a020..25bbb70 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -11,6 +11,7 @@ export function formatUptime(seconds: number): string { import i18next from 'i18next'; import { currencyApi, type ExchangeRates } from '../api/currency'; +import { uiLocale } from './uiLocale'; const LANG_CURRENCY_MAP: Record< string, @@ -60,18 +61,10 @@ export function formatPrice(kopeks: number, lang?: string): string { } } -const SHORT_DATE_LOCALE_MAP: Record = { - ru: 'ru-RU', - en: 'en-US', - zh: 'zh-CN', - fa: 'fa-IR', -}; - /** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */ export function formatShortDate(date: string | null): string { if (!date) return '-'; - const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU'; - return new Date(date).toLocaleDateString(locale, { + return new Date(date).toLocaleDateString(uiLocale(), { day: '2-digit', month: '2-digit', year: 'numeric', diff --git a/src/utils/uiLocale.test.ts b/src/utils/uiLocale.test.ts new file mode 100644 index 0000000..7735a14 --- /dev/null +++ b/src/utils/uiLocale.test.ts @@ -0,0 +1,36 @@ +import i18next from 'i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { uiLocale } from './uiLocale'; + +describe('uiLocale', () => { + beforeAll(async () => { + await i18next.init({ lng: 'ru', resources: {} }); + }); + + it('маппит язык интерфейса в BCP-47 тег', async () => { + await i18next.changeLanguage('en'); + expect(uiLocale()).toBe('en-US'); + await i18next.changeLanguage('zh'); + expect(uiLocale()).toBe('zh-CN'); + await i18next.changeLanguage('fa'); + expect(uiLocale()).toBe('fa-IR'); + }); + + it('режет региональный суффикс языка', async () => { + await i18next.changeLanguage('en-GB'); + expect(uiLocale()).toBe('en-US'); + }); + + it('падает в ru-RU для неизвестного языка', async () => { + await i18next.changeLanguage('xx'); + expect(uiLocale()).toBe('ru-RU'); + }); + + it('дата форматируется по языку интерфейса, а не браузера', async () => { + await i18next.changeLanguage('en'); + const date = new Date('2026-07-13T12:00:00Z'); + expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('7/13/2026'); + await i18next.changeLanguage('ru'); + expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('13.07.2026'); + }); +}); diff --git a/src/utils/uiLocale.ts b/src/utils/uiLocale.ts new file mode 100644 index 0000000..30c337a --- /dev/null +++ b/src/utils/uiLocale.ts @@ -0,0 +1,18 @@ +import i18next from 'i18next'; + +const LOCALE_MAP: Record = { + ru: 'ru-RU', + en: 'en-US', + zh: 'zh-CN', + fa: 'fa-IR', +}; + +/** + * BCP-47 тег активного языка интерфейса для toLocale*-методов и Intl-API. + * Без него даты/числа форматируются локалью браузера (или жёстким 'ru-RU') + * и не реагируют на смену языка в приложении. + */ +export function uiLocale(): string { + const lang = (i18next.language || '').split('-')[0]; + return LOCALE_MAP[lang] || 'ru-RU'; +} From 2b5152608c6a8f5781f2c1d856b75b1c57c6d579 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:10:59 +0300 Subject: [PATCH 03/10] =?UTF-8?q?test(i18n):=20vitest-=D1=87=D0=B5=D0=BA?= =?UTF-8?q?=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20en/ru=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20+=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B2=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 20 ++++++- src/locales/locales.test.ts | 101 ++++++++++++++++++++++++++++++++++++ src/locales/ru.json | 2 + 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/locales/locales.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5630838..92212a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,26 @@ jobs: lint: uses: ./.github/workflows/lint.yml + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + build: - needs: lint + needs: [lint, test] runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/src/locales/locales.test.ts b/src/locales/locales.test.ts new file mode 100644 index 0000000..f8481c7 --- /dev/null +++ b/src/locales/locales.test.ts @@ -0,0 +1,101 @@ +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.', +]; + +// Известные дыры на момент добавления теста. НЕ добавлять сюда новые ключи — +// переводите en.json. Записи ниже удалить после мержа PR #489. +const KNOWN_MISSING_IN_EN = new Set([ + 'admin.remnawave.traffic.realtimeTitle', + 'dashboard.expired.expiredDate_trial', + 'resetPassword.enterNewPassword', + 'resetPassword.invalidToken', + 'resetPassword.redirectingToLogin', + 'resetPassword.setPassword', + 'resetPassword.success', + 'resetPassword.title', + 'resetPassword.tokenExpiredOrInvalid', +]); + +// Удалить после мержа PR #489 (en-значение получит {{count}}). +const KNOWN_PLACEHOLDER_MISMATCHES = new Set(['promo.offers.serverAccess']); + +// Плюральные категории i18next: ru использует _one/_few/_many, en — _one/_other. +// Сравниваем БАЗОВЫЕ ключи (без плюрального суффикса); context-варианты +// (напр. _trial) — самостоятельные ключи и обязаны существовать в обеих локалях. +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; + +type Tree = { [key: string]: Tree | string }; + +function flatten(tree: Tree, prefix = ''): Map { + const out = new Map(); + for (const [key, value] of Object.entries(tree)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') { + out.set(path, value); + } else { + for (const [k, v] of flatten(value, path)) { + out.set(k, v); + } + } + } + return out; +} + +const enFlat = flatten(en as Tree); +const ruFlat = flatten(ru as Tree); + +function baseKeys(flat: Map): Set { + return new Set([...flat.keys()].map((k) => k.replace(PLURAL_SUFFIX, ''))); +} + +const enBases = baseKeys(enFlat); +const ruBases = baseKeys(ruFlat); + +describe('синхронность локалей en/ru', () => { + it('каждый en-ключ существует в ru', () => { + const missing = [...enBases].filter((k) => !ruBases.has(k)); + expect(missing).toEqual([]); + }); + + it('каждый ru-ключ существует в en (кроме ru-only словарей настроек)', () => { + const missing = [...ruBases].filter( + (k) => + !enBases.has(k) && + !RU_ONLY_NAMESPACES.some((ns) => k.startsWith(ns)) && + !KNOWN_MISSING_IN_EN.has(k), + ); + expect(missing).toEqual([]); + }); + + it('плейсхолдеры {{...}} совпадают в общих ключах', () => { + const PH = /\{\{[^}]+\}\}/g; + const mismatches: string[] = []; + for (const [key, ruValue] of ruFlat) { + const enValue = enFlat.get(key); + if (enValue === undefined || KNOWN_PLACEHOLDER_MISMATCHES.has(key)) { + continue; + } + const ruPh = (ruValue.match(PH) ?? []).sort().join(','); + const enPh = (enValue.match(PH) ?? []).sort().join(','); + if (ruPh !== enPh) { + mismatches.push(`${key}: ru=[${ruPh}] en=[${enPh}]`); + } + } + expect(mismatches).toEqual([]); + }); +}); diff --git a/src/locales/ru.json b/src/locales/ru.json index 79f6820..d9044ea 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2924,6 +2924,7 @@ "namePlaceholder": "Введите название тарифа", "description": "Описание", "descriptionPlaceholder": "Краткое описание тарифа", + "descriptionPlaceholder2": "Краткое описание тарифа", "trafficLimit": "Лимит трафика", "trafficHint": "0 = безлимитный трафик", "deviceLimit": "Лимит устройств", @@ -3814,6 +3815,7 @@ "purchases": "Покупок", "campaign": "Рекламная кампания", "referralsList": "Список рефералов", + "referralEarnings": "Реф. доход", "noReferrals": "Нет рефералов", "panelConfig": "Конфигурация панели", "trojanPassword": "Trojan пароль", From 2f743d82dbb4d737fcb5f54dda5269271bae3de2 Mon Sep 17 00:00:00 2001 From: Case211 <87642841+Case211@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:28:33 +0500 Subject: [PATCH 04/10] =?UTF-8?q?feat(users):=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=C2=AB=D0=9E=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=C2=BB=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паритет с бот-кнопкой «✉️ Отправить сообщение»: в блоке действий карточки юзера появляется кнопка (RBAC users:send_message), открывающая модалку с текстом (HTML, лимит 4096) — бот отправляет юзеру прямое сообщение через POST /admin/users/{id}/send-message. Для email-only юзеров без telegram_id кнопка задизейблена с подсказкой; коды ошибок бэкенда (no_telegram_id / forbidden / bad_request) маппятся в честные локализованные сообщения. Локали ru/en/fa/zh. --- src/api/adminUsers.ts | 9 ++ src/components/admin/userDetail/InfoTab.tsx | 104 +++++++++++++++++++- src/locales/en.json | 14 +++ src/locales/fa.json | 14 +++ src/locales/ru.json | 14 +++ src/locales/zh.json | 14 +++ 6 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index bc9a053..1bc20f2 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -535,6 +535,15 @@ export const adminUsersApi = { return response.data; }, + // Send direct Telegram message to user via bot (parity with bot's admin action) + sendMessage: async ( + userId: number, + text: string, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post(`/cabinet/admin/users/${userId}/send-message`, { text }); + return response.data; + }, + // Update restrictions updateRestrictions: async ( userId: number, diff --git a/src/components/admin/userDetail/InfoTab.tsx b/src/components/admin/userDetail/InfoTab.tsx index 7438ae3..22ecf6e 100644 --- a/src/components/admin/userDetail/InfoTab.tsx +++ b/src/components/admin/userDetail/InfoTab.tsx @@ -1,12 +1,15 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { useNotify } from '../../../platform/hooks/useNotify'; import { useCurrency } from '../../../hooks/useCurrency'; import { createNumberInputHandler } from '../../../utils/inputHelpers'; -import type { - UserDetailResponse, - UserListItem, - UserPanelInfo, - UserSubscriptionInfo, +import { + adminUsersApi, + type UserDetailResponse, + type UserListItem, + type UserPanelInfo, + type UserSubscriptionInfo, } from '../../../api/adminUsers'; import type { PromoGroup } from '../../../api/promocodes'; import { ServerIcon } from '@/components/icons'; @@ -92,6 +95,12 @@ export function InfoTab(props: InfoTabProps) { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); + const notify = useNotify(); + + // «Отправить сообщение» — паритет с бот-кнопкой в карточке юзера + const [sendMsgOpen, setSendMsgOpen] = useState(false); + const [sendMsgText, setSendMsgText] = useState(''); + const [sendMsgLoading, setSendMsgLoading] = useState(false); const { user, hasPermission, @@ -124,6 +133,32 @@ export function InfoTab(props: InfoTabProps) { onFullDeleteUser, } = props; + const handleSendMessage = async () => { + const text = sendMsgText.trim(); + if (!text || sendMsgLoading) return; + setSendMsgLoading(true); + try { + await adminUsersApi.sendMessage(user.id, text); + notify.success(t('admin.users.sendMessage.success'), t('common.success')); + setSendMsgOpen(false); + setSendMsgText(''); + } catch (err) { + const detail = ( + err as { response?: { data?: { detail?: { code?: string; message?: string } | string } } } + )?.response?.data?.detail; + const code = typeof detail === 'object' ? detail?.code : undefined; + const known = ['no_telegram_id', 'forbidden', 'bad_request']; + const message = + code && known.includes(code) + ? t(`admin.users.sendMessage.errors.${code}`) + : (typeof detail === 'object' ? detail?.message : detail) || + t('admin.users.userActions.error'); + notify.error(message, t('common.error')); + } finally { + setSendMsgLoading(false); + } + }; + return (
{/* Status */} @@ -470,6 +505,16 @@ export function InfoTab(props: InfoTabProps) { {t('admin.users.detail.actions.title')}
+ {hasPermission('users:send_message') && ( + + )}
+ + {/* Send message modal */} + {sendMsgOpen && ( +
+
!sendMsgLoading && setSendMsgOpen(false)} + aria-hidden="true" + /> +
+

+ {t('admin.users.sendMessage.title')} +

+