mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix(i18n): форматировать даты и числа локалью выбранного языка, а не браузера
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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