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'; +}