Merge PR #490: форматирование дат и чисел локалью выбранного языка

This commit is contained in:
Egor
2026-07-19 16:18:50 +03:00
committed by GitHub
19 changed files with 108 additions and 40 deletions

View File

@@ -3,6 +3,7 @@
* Shows prominent success messages for balance top-ups and subscription purchases.
*/
import { uiLocale } from '@/utils/uiLocale';
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -96,7 +97,7 @@ export default function SuccessNotificationModal() {
// Format expiry date
const formattedExpiry = data.expiresAt
? new Date(data.expiresAt).toLocaleDateString(undefined, {
? new Date(data.expiresAt).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric',

View File

@@ -1,7 +1,8 @@
import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { Link } from 'react-router';
import { UseMutationResult } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
import TrafficProgressBar from './TrafficProgressBar';
import Sparkline from './Sparkline';
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
@@ -48,7 +49,7 @@ export default function SubscriptionCardActive({
const isAtDeviceLimit =
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
const daysLeft = subscription.days_left;
// Sparkline placeholder data (hidden until API provides daily usage)

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useLocation } from 'react-router';
@@ -37,7 +38,7 @@ export default function SubscriptionCardExpired({
const [isRenewing, setIsRenewing] = useState(false);
const [renewError, setRenewError] = useState<string | null>(null);
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
// Detect limited (traffic exhausted) state
const isLimited = subscription.is_limited;

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -88,7 +89,7 @@ function formatCountdown(seconds: number): string {
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
return new Date(dateStr).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
@@ -99,7 +100,7 @@ function formatDate(dateStr: string | null): string {
}
function formatBalance(kopeks: number): string {
return Math.floor(kopeks / 100).toLocaleString();
return Math.floor(kopeks / 100).toLocaleString(uiLocale());
}
// -- Radio Indicator --

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
@@ -303,7 +304,7 @@ export default function Profile() {
<div className="flex items-center justify-between py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>
<span className="font-medium text-dark-100">
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
{user?.created_at ? new Date(user.created_at).toLocaleDateString(uiLocale()) : '-'}
</span>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
@@ -77,7 +78,8 @@ export default function PublicLegal({ doc }: PublicLegalProps) {
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
{data.updated_at && (
<p className="mt-4 text-xs text-dark-500">
{t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
{t('info.updatedAt', 'Обновлено')}:{' '}
{new Date(data.updated_at).toLocaleDateString(uiLocale())}
</p>
)}
</div>

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import {
@@ -25,19 +26,19 @@ export function NetworkStats({ data, className }: NetworkStatsProps) {
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:gap-x-6 sm:gap-y-2">
<StatCard
label={t('admin.referralNetwork.stats.totalUsers')}
value={data.total_users.toLocaleString()}
value={data.total_users.toLocaleString(uiLocale())}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalReferrers')}
value={data.total_referrers.toLocaleString()}
value={data.total_referrers.toLocaleString(uiLocale())}
icon={<PartnerIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalCampaigns')}
value={data.total_campaigns.toLocaleString()}
value={data.total_campaigns.toLocaleString(uiLocale())}
icon={<CampaignIcon className="h-5 w-5" />}
tone="neutral"
/>

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { referralNetworkApi } from '@/api/referralNetwork';
@@ -115,7 +116,7 @@ export function UserDetailPanel({ userId, className }: UserDetailPanelProps) {
{user.subscription_end && (
<p className="mt-0.5 text-xs text-dark-400">
{t('admin.referralNetwork.user.validUntil', {
date: new Date(user.subscription_end).toLocaleDateString(),
date: new Date(user.subscription_end).toLocaleDateString(uiLocale()),
})}
</p>
)}

View File

@@ -1,10 +1,11 @@
import { uiLocale } from '@/utils/uiLocale';
import type { SubscriptionStatus } from '@/types/referralNetwork';
/**
* Format kopeks to a human-readable ruble string.
*/
export function formatKopeksToRubles(kopeks: number): string {
return `${(kopeks / 100).toLocaleString('ru-RU')}`;
return `${(kopeks / 100).toLocaleString(uiLocale())}`;
}
/**

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -17,7 +18,7 @@ function formatCardDate(dateStr: string): string {
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return date.toLocaleDateString();
return date.toLocaleDateString(uiLocale());
} catch {
return dateStr;
}

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -73,7 +74,7 @@ const CountdownTimer = memo(function CountdownTimer({
const isExpired = !isActive;
const isUrgent = countdown.days <= 3;
const formattedDate = new Date(endDate).toLocaleDateString(undefined, {
const formattedDate = new Date(endDate).toLocaleDateString(uiLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
@@ -997,7 +998,7 @@ export default function Subscription() {
</div>
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
{t('subscription.trafficResetAt')}:{' '}
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
{new Date(purchase.expires_at).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@@ -1017,8 +1018,12 @@ export default function Subscription() {
/>
</div>
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
<span>
{new Date(purchase.created_at).toLocaleDateString(uiLocale())}
</span>
<span>
{new Date(purchase.expires_at).toLocaleDateString(uiLocale())}
</span>
</div>
</div>
))}
@@ -1203,7 +1208,7 @@ export default function Subscription() {
</div>
<div className="mt-1 text-[12px] text-dark-50/35">
{t('subscription.pause.pausedDescription')}{' '}
{new Date(subscription.end_date).toLocaleDateString()} (
{new Date(subscription.end_date).toLocaleDateString(uiLocale())} (
{t('subscription.pause.days', { count: subscription.days_left })})
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -411,7 +412,7 @@ export default function Support() {
</span>
</div>
<div className="text-xs text-dark-500">
{new Date(ticket.updated_at).toLocaleDateString()}
{new Date(ticket.updated_at).toLocaleDateString(uiLocale())}
</div>
</button>
))}
@@ -558,7 +559,7 @@ export default function Support() {
</span>
<span className="text-xs text-dark-500">
{t('support.created')}{' '}
{new Date(selectedTicket.created_at).toLocaleDateString()}
{new Date(selectedTicket.created_at).toLocaleDateString(uiLocale())}
</span>
</div>
</div>
@@ -587,7 +588,7 @@ export default function Support() {
{msg.is_from_admin ? t('support.supportTeam') : t('support.you')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
{new Date(msg.created_at).toLocaleString(uiLocale())}
</span>
</div>
{msg.message_text && (

View File

@@ -1,3 +1,4 @@
import { uiLocale } from '@/utils/uiLocale';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -798,7 +799,7 @@ export default function Wheel() {
{item.prize_display_name}
</div>
<div className="text-xs text-dark-500">
{new Date(item.created_at).toLocaleDateString()}
{new Date(item.created_at).toLocaleDateString(uiLocale())}
</div>
</div>
</div>

View File

@@ -11,6 +11,7 @@ export function formatUptime(seconds: number): string {
import i18next from 'i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
import { uiLocale } from './uiLocale';
const LANG_CURRENCY_MAP: Record<
string,
@@ -60,18 +61,10 @@ export function formatPrice(kopeks: number, lang?: string): string {
}
}
const SHORT_DATE_LOCALE_MAP: Record<string, string> = {
ru: 'ru-RU',
en: 'en-US',
zh: 'zh-CN',
fa: 'fa-IR',
};
/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
export function formatShortDate(date: string | null): string {
if (!date) return '-';
const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
return new Date(date).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',

View File

@@ -0,0 +1,36 @@
import i18next from 'i18next';
import { beforeAll, describe, expect, it } from 'vitest';
import { uiLocale } from './uiLocale';
describe('uiLocale', () => {
beforeAll(async () => {
await i18next.init({ lng: 'ru', resources: {} });
});
it('маппит язык интерфейса в BCP-47 тег', async () => {
await i18next.changeLanguage('en');
expect(uiLocale()).toBe('en-US');
await i18next.changeLanguage('zh');
expect(uiLocale()).toBe('zh-CN');
await i18next.changeLanguage('fa');
expect(uiLocale()).toBe('fa-IR');
});
it('режет региональный суффикс языка', async () => {
await i18next.changeLanguage('en-GB');
expect(uiLocale()).toBe('en-US');
});
it('падает в ru-RU для неизвестного языка', async () => {
await i18next.changeLanguage('xx');
expect(uiLocale()).toBe('ru-RU');
});
it('дата форматируется по языку интерфейса, а не браузера', async () => {
await i18next.changeLanguage('en');
const date = new Date('2026-07-13T12:00:00Z');
expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('7/13/2026');
await i18next.changeLanguage('ru');
expect(date.toLocaleDateString(uiLocale(), { timeZone: 'UTC' })).toBe('13.07.2026');
});
});

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

@@ -0,0 +1,18 @@
import i18next from 'i18next';
const LOCALE_MAP: Record<string, string> = {
ru: 'ru-RU',
en: 'en-US',
zh: 'zh-CN',
fa: 'fa-IR',
};
/**
* BCP-47 тег активного языка интерфейса для toLocale*-методов и Intl-API.
* Без него даты/числа форматируются локалью браузера (или жёстким 'ru-RU')
* и не реагируют на смену языка в приложении.
*/
export function uiLocale(): string {
const lang = (i18next.language || '').split('-')[0];
return LOCALE_MAP[lang] || 'ru-RU';
}