Merge pull request #425 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-05-13 11:38:18 +03:00
committed by GitHub
21 changed files with 254 additions and 46 deletions

View File

@@ -228,7 +228,11 @@ export default function SubscriptionCardExpired({
>
<div className="flex items-center">
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{isLimited ? t('dashboard.expired.activeUntil') : t('dashboard.expired.expiredDate')}
{isLimited
? t('dashboard.expired.activeUntil')
: t('dashboard.expired.expiredDate', {
context: subscription.is_trial ? 'trial' : '',
})}
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}

View File

@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
import { initDataUser } from '@telegram-apps/sdk-react';
import { useAuthStore } from '@/store/auth';
import { displayName } from '@/utils/displayName';
import { useShallow } from 'zustand/shallow';
import { useTheme } from '@/hooks/useTheme';
import { usePlatform } from '@/platform';
@@ -340,9 +341,7 @@ export function AppHeader({
<UserIcon className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-sm font-medium text-dark-100">{displayName(user)}</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>

View File

@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { displayName } from '@/utils/displayName';
import {
brandingApi,
getCachedBranding,
@@ -188,9 +189,7 @@ export function DesktopSidebar({
<UserIcon className="h-4 w-4 text-dark-400" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-dark-100">
{user?.first_name || user?.username || `#${user?.telegram_id}`}
</p>
<p className="truncate text-sm font-medium text-dark-100">{displayName(user)}</p>
<p className="truncate text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</p>

View File

@@ -136,21 +136,21 @@ function syncYandexCid(counterId: string) {
if (!token) return;
// Route through brandingApi (apiClient) so baseURL, auth refresh, and
// error handling all flow through the same interceptors as every other
// cabinet API call.
import('../api/branding').then(({ brandingApi: api }) => {
api
.storeYandexCid(cid)
.then(() => {
try {
localStorage.setItem(SENT_KEY, '1');
} catch {
/* ignore */
}
})
.catch(() => {
/* swallow -- non-critical, will retry on next login */
});
});
// cabinet API call. brandingApi уже импортирован статически — динамический
// import('../api/branding') ломал code-splitting (Vite warning о том, что
// модуль не может быть вынесен в отдельный chunk при mixed static+dynamic).
brandingApi
.storeYandexCid(cid)
.then(() => {
try {
localStorage.setItem(SENT_KEY, '1');
} catch {
/* ignore */
}
})
.catch(() => {
/* swallow -- non-critical, will retry on next login */
});
});
} catch {
/* ignore */

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
import { setExchangeRates as setGlobalExchangeRates } from '../utils/format';
// Map language to currency
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
@@ -30,6 +31,12 @@ export function useCurrency() {
retry: 2,
});
// Делимся курсом с синхронным formatPrice из utils/format.ts —
// у него нет своего источника rates, а на лендинге его дёргают subcomponents.
useEffect(() => {
setGlobalExchangeRates(exchangeRates);
}, [exchangeRates]);
// Get current language and currency
const currentLanguage = i18n.language;
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';

View File

@@ -1515,6 +1515,8 @@
"noMethods": "No payment methods configured",
"methodEnabled": "Method enabled",
"providerNotConfigured": "Provider not configured in env",
"openUrlDirect": "Open payment page directly",
"openUrlDirectHint": "Skip the link panel — the provider opens inside the MiniApp/tab right after the click. After payment the user returns to /balance/top-up/result. Ignored for Stars/CryptoBot and other t.me/ links.",
"displayName": "Display name",
"displayNameHint": "Leave empty for default name",
"subOptions": "Payment options",

View File

@@ -2082,6 +2082,8 @@
"noMethods": "هیچ روش پرداخت پیکربندی‌شده‌ای نیست",
"methodEnabled": "روش فعال است",
"providerNotConfigured": "ارائه‌دهنده در env تنظیم نشده",
"openUrlDirect": "صفحه پرداخت را مستقیماً باز کن",
"openUrlDirectHint": "بدون پنل لینک — ارائه‌دهنده داخل MiniApp/تب بلافاصله پس از کلیک باز می‌شود. پس از پرداخت کاربر به /balance/top-up/result بازمی‌گردد. برای Stars/CryptoBot و سایر لینک‌های t.me/ این پرچم نادیده گرفته می‌شود.",
"displayName": "نام نمایشی",
"displayNameHint": "برای نام پیش‌فرض خالی بگذارید",
"subOptions": "گزینه‌های پرداخت",

View File

@@ -285,6 +285,7 @@
"traffic": "Трафик",
"devices": "Устройства",
"expiredDate": "Истекла",
"expiredDate_trial": "Истёк",
"activeUntil": "Активна до"
},
"suspended": {
@@ -1536,6 +1537,8 @@
"noMethods": "Нет настроенных платёжных методов",
"methodEnabled": "Метод включён",
"providerNotConfigured": "Провайдер не настроен в env",
"openUrlDirect": "Открывать страницу оплаты сразу",
"openUrlDirectHint": "Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
"displayName": "Отображаемое имя",
"displayNameHint": "Оставьте пустым для имени по умолчанию",
"subOptions": "Варианты оплаты",

View File

@@ -1280,6 +1280,8 @@
"noMethods": "没有配置的支付方法",
"methodEnabled": "方法已启用",
"providerNotConfigured": "提供商未在env中配置",
"openUrlDirect": "直接打开支付页面",
"openUrlDirectHint": "跳过链接面板 — 点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
"displayName": "显示名称",
"displayNameHint": "留空以使用默认名称",
"subOptions": "支付选项",

View File

@@ -13,6 +13,7 @@ import {
} from '../api/landings';
import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
import { formatPrice } from '../utils/format';
import { useCurrency } from '../hooks/useCurrency';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor';
@@ -97,6 +98,9 @@ export default function AdminLandingEditor() {
const { capabilities } = usePlatform();
const isEdit = !!id;
// Прогреваем кэш курсов валют для formatPrice (preview лендинга в не-RU локали).
useCurrency();
// Section visibility
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
general: true,

View File

@@ -66,6 +66,7 @@ export default function AdminPaymentMethodEdit() {
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
const [openUrlDirect, setOpenUrlDirect] = useState(false);
// Initialize state when config loads
useEffect(() => {
@@ -79,6 +80,8 @@ export default function AdminPaymentMethodEdit() {
setFirstTopupFilter(config.first_topup_filter);
setPromoGroupFilterMode(config.promo_group_filter_mode);
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
setOpenUrlDirect(config.open_url_direct ?? false);
}
}, [config]);
@@ -100,6 +103,7 @@ export default function AdminPaymentMethodEdit() {
first_topup_filter: firstTopupFilter,
promo_group_filter_mode: promoGroupFilterMode,
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
open_url_direct: openUrlDirect,
};
// Display name
@@ -215,6 +219,34 @@ export default function AdminPaymentMethodEdit() {
</button>
</div>
{/* Open URL directly toggle */}
<div className="flex items-center justify-between border-t border-dark-800 pt-6">
<div className="pr-4">
<div className="text-sm font-medium text-dark-200">
{t('admin.paymentMethods.openUrlDirect', 'Открывать страницу оплаты сразу')}
</div>
<div className="mt-0.5 text-xs text-dark-500">
{t(
'admin.paymentMethods.openUrlDirectHint',
'Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/result.',
)}
</div>
</div>
<button
onClick={() => setOpenUrlDirect(!openUrlDirect)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
openUrlDirect ? 'bg-accent-500' : 'bg-dark-600'
}`}
aria-label={t('admin.paymentMethods.openUrlDirect', 'Open payment page directly')}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
openUrlDirect ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
{/* Display name */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { displayName } from '../utils/displayName';
import { useBlockingStore } from '../store/blocking';
import { subscriptionApi } from '../api/subscription';
import { referralApi } from '../api/referral';
@@ -195,7 +196,12 @@ export default function Dashboard() {
refreshTrafficMutation.mutate();
}, [subscription, refreshTrafficMutation]);
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
// В multi-tariff /cabinet/subscription отключён, поэтому subscriptionResponse=undefined.
// Используем список из /cabinet/subscriptions/list — пустой массив означает «нет подписок»,
// и тогда показываем TrialOfferCard. Без этой ветки multi-tariff юзер никогда не видел триал.
const hasNoSubscription = isMultiTariff
? multiSubData !== undefined && (multiSubData.subscriptions?.length ?? 0) === 0
: subscriptionResponse?.has_subscription === false && !subLoading;
// Show onboarding for new users after data loads
useEffect(() => {
@@ -249,7 +255,7 @@ export default function Dashboard() {
{/* Header */}
<div data-onboarding="welcome">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
{t('dashboard.welcome', { name: displayName(user) })}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-dark-400">{t('dashboard.yourSubscription')}</p>

View File

@@ -19,6 +19,7 @@ import { cn } from '../lib/utils';
import { copyToClipboard } from '../utils/clipboard';
import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format';
import { useCurrency } from '../hooks/useCurrency';
import { usePlatform, useHaptic } from '@/platform';
function GiftIcon({ className }: { className?: string }) {
@@ -1286,6 +1287,9 @@ export default function GiftSubscription() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
// Прогреваем кэш курсов валют для formatPrice (см. QuickPurchase).
useCurrency();
// URL params: ?tab=activate&code=TOKEN for auto-activation
const urlTab = searchParams.get('tab') as TabId | null;
const rawCode = searchParams.get('code');

View File

@@ -5,6 +5,7 @@ import { usePlatform } from '@/platform';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../store/auth';
import { displayName } from '../utils/displayName';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import {
@@ -333,9 +334,7 @@ export default function Profile() {
)}
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
<span className="text-dark-400">{t('profile.name')}</span>
<span className="font-medium text-dark-100">
{user?.first_name} {user?.last_name}
</span>
<span className="font-medium text-dark-100">{displayName(user)}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-dark-400">{t('profile.registeredAt')}</span>

View File

@@ -19,6 +19,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format';
import { useCurrency } from '../hooks/useCurrency';
function detectContactType(value: string): 'email' | 'telegram' {
return value.startsWith('@') ? 'telegram' : 'email';
@@ -795,6 +796,10 @@ export default function QuickPurchase() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
// Подгружаем курсы валют, чтобы formatPrice конвертировал суммы для не-RU локалей
// (хук кладёт rates в глобальный кэш utils/format.ts).
useCurrency();
// Fetch config
const {
data: config,

View File

@@ -1576,10 +1576,11 @@ export default function Subscription() {
<div className="mb-2 text-sm text-dark-400">
{/* Show original price with strikethrough if discount */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 ? (
devicePriceData.discount_percent > 0 &&
devicePriceData.original_price_per_device_kopeks ? (
<span>
<span className="text-dark-500 line-through">
{formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
{formatPrice(devicePriceData.original_price_per_device_kopeks)}
</span>
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
</span>

View File

@@ -1,10 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Navigate, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { subscriptionApi } from '../api/subscription';
import { balanceApi } from '../api/balance';
import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme';
import { useAuthStore } from '../store/auth';
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
function EmptyState({ onBuy }: { onBuy: () => void }) {
const { t } = useTranslation();
@@ -55,6 +59,9 @@ export default function Subscriptions() {
const navigate = useNavigate();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const queryClient = useQueryClient();
const refreshUser = useAuthStore((state) => state.refreshUser);
const [trialError, setTrialError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['subscriptions-list'],
@@ -65,6 +72,39 @@ export default function Subscriptions() {
const subscriptions = data?.subscriptions ?? [];
const isMultiTariff = data?.multi_tariff_enabled ?? false;
const hasNoSubscriptions = !isLoading && subscriptions.length === 0;
// Если у юзера нет подписок — проверяем доступность триала, иначе
// (в multi-tariff) ему вообще негде увидеть оффер.
const { data: trialInfo, isLoading: trialLoading } = useQuery({
queryKey: ['trial-info'],
queryFn: () => subscriptionApi.getTrialInfo(),
enabled: hasNoSubscriptions,
staleTime: 30_000,
});
const { data: balanceData } = useQuery({
queryKey: ['balance'],
queryFn: balanceApi.getBalance,
enabled: hasNoSubscriptions && !!trialInfo?.is_available,
staleTime: 30_000,
});
const activateTrialMutation = useMutation({
mutationFn: () => subscriptionApi.activateTrial(),
onSuccess: () => {
setTrialError(null);
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
setTrialError(error.response?.data?.detail || t('common.error'));
},
});
// Single-tariff mode with one subscription: skip list, go directly to detail
if (data && !isMultiTariff && subscriptions.length === 1) {
@@ -115,8 +155,17 @@ export default function Subscriptions() {
</div>
)}
{/* Empty state */}
{!isLoading && subscriptions.length === 0 && (
{/* Empty state: показываем триал, если доступен; иначе — обычный empty */}
{hasNoSubscriptions && !trialLoading && trialInfo?.is_available && (
<TrialOfferCard
trialInfo={trialInfo}
balanceKopeks={balanceData?.balance_kopeks ?? 0}
balanceRubles={balanceData?.balance_rubles ?? 0}
activateTrialMutation={activateTrialMutation}
trialError={trialError}
/>
)}
{hasNoSubscriptions && !trialLoading && !trialInfo?.is_available && (
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
)}

View File

@@ -170,7 +170,8 @@ export default function TopUpAmount() {
: converted.toFixed(2);
};
const [amount, setAmount] = useState(getInitialAmount);
const initialDisplayAmount = getInitialAmount();
const [amount, setAmount] = useState(initialDisplayAmount);
const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>(
getPreferredOptionId(method?.options),
@@ -254,9 +255,8 @@ export default function TopUpAmount() {
onSuccess: (data) => {
const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) {
setPaymentUrl(redirectUrl);
// Save payment info for the result page
// Save payment info for the result page (do BEFORE possible redirect,
// иначе после window.location.href этот код не выполнится).
if (method && data.payment_id) {
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const displayName =
@@ -269,6 +269,29 @@ export default function TopUpAmount() {
created_at: Date.now(),
});
}
// open_url_direct: seamless флоу как при покупке подарка.
// window.location.href внутри Telegram MiniApp WebView навигирует
// в том же контейнере без открытия внешнего браузера. После
// оплаты return_url возвращает на /balance/top-up/result.
//
// t.me/ URL (Telegram Stars, CryptoBot) — всегда через нативный
// handler (openInvoice / openTelegramLink в setPaymentUrl-ветке).
// Stars уже отбит раньше через starsPaymentMutation, здесь — защита
// на случай CryptoBot и других Telegram-deep-link провайдеров.
// toLowerCase для устойчивости к редким провайдерам, которые могут вернуть
// URL в нестандартном регистре. Также покрываем tg:// scheme на всякий случай.
const lowerUrl = redirectUrl.toLowerCase();
const isTelegramDeepLink =
lowerUrl.startsWith('https://t.me/') ||
lowerUrl.startsWith('http://t.me/') ||
lowerUrl.startsWith('tg://');
if (method?.open_url_direct && !isTelegramDeepLink) {
window.location.href = redirectUrl;
return;
}
setPaymentUrl(redirectUrl);
}
},
onError: (err: unknown) => {
@@ -334,7 +357,22 @@ export default function TopUpAmount() {
return;
}
const amountKopeks = Math.round(amountRubles * 100);
// Сохраняем canonical RUB amount если юзер НЕ редактировал префилл.
// Display-rounding в `.toFixed(2)` теряет точность: 150₽ при rate=90.66 → "1.65" USD
// (округление вниз с 1.6545), back-конвертация даёт 1.65 × 90.66 = 149.589₽ < 150₽
// → юзер не может купить подписку 150₽. С canonical RUB обходим FX round-trip.
//
// Math.ceil для не-RUB локалей покрывает остаточные sub-копеечные ошибки
// floating-point, когда юзер реально вводит свой amount.
const userEditedAmount = amount.trim() !== initialDisplayAmount.trim();
let amountKopeks: number;
if (!userEditedAmount && initialAmountRubles && initialAmountRubles > 0) {
amountKopeks = Math.round(initialAmountRubles * 100);
} else if (targetCurrency === 'RUB') {
amountKopeks = Math.round(amountRubles * 100);
} else {
amountKopeks = Math.ceil(amountRubles * 100);
}
if (isStarsMethod) {
starsPaymentMutation.mutate(amountKopeks);
} else {

View File

@@ -442,6 +442,9 @@ export interface PaymentMethod {
max_amount_kopeks: number;
is_available: boolean;
options?: PaymentMethodOption[] | null;
// Если true — после получения payment_url кабинет сразу делает
// window.location.href вместо показа панели с кнопкой "Открыть".
open_url_direct?: boolean;
}
// Referral types
@@ -682,6 +685,7 @@ export interface PaymentMethodConfig {
first_topup_filter: 'any' | 'yes' | 'no';
promo_group_filter_mode: 'all' | 'selected';
allowed_promo_group_ids: number[];
open_url_direct: boolean;
is_provider_configured: boolean;
created_at: string | null;
updated_at: string | null;

23
src/utils/displayName.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Composes a user-facing display name from first_name + last_name with sensible fallbacks.
*
* Why: single-letter first_name (e.g., "О") looked confusing alone ("Добро пожаловать, О!").
* Combining with last_name makes truncated/short first names readable.
*/
export interface NameSource {
first_name?: string | null;
last_name?: string | null;
username?: string | null;
telegram_id?: number | null;
}
export function displayName(user?: NameSource | null): string {
if (!user) return '';
const fullName = [user.first_name, user.last_name]
.filter((part): part is string => Boolean(part && part.trim()))
.join(' ');
if (fullName) return fullName;
if (user.username) return user.username;
if (user.telegram_id) return `#${user.telegram_id}`;
return '';
}

View File

@@ -10,27 +10,52 @@ export function formatUptime(seconds: number): string {
}
import i18next from 'i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
const LANG_CURRENCY_MAP: Record<string, { currency: string; locale: string; symbol: string }> = {
const LANG_CURRENCY_MAP: Record<
string,
{ currency: string; locale: string; symbol: string; key?: keyof ExchangeRates }
> = {
ru: { currency: 'RUB', locale: 'ru-RU', symbol: '₽' },
en: { currency: 'USD', locale: 'en-US', symbol: '$' },
zh: { currency: 'CNY', locale: 'zh-CN', symbol: '¥' },
fa: { currency: 'IRR', locale: 'fa-IR', symbol: '﷼' },
en: { currency: 'USD', locale: 'en-US', symbol: '$', key: 'USD' },
zh: { currency: 'CNY', locale: 'zh-CN', symbol: '¥', key: 'CNY' },
fa: { currency: 'IRR', locale: 'fa-IR', symbol: '﷼', key: 'IRR' },
};
const DEFAULT_CURRENCY = { currency: 'RUB', locale: 'ru-RU', symbol: '₽' };
// Глобальный кэш курсов. Заполняется один раз из useCurrency (см. setExchangeRates ниже)
// и используется здесь синхронно. Без него formatPrice падал в "просто замена символа",
// что давало пользователю на лендинге `¥220` вместо реально конвертированной суммы.
let cachedExchangeRates: ExchangeRates | null = null;
export function setExchangeRates(rates: ExchangeRates | null): void {
cachedExchangeRates = rates;
}
export function formatPrice(kopeks: number, lang?: string): string {
const resolvedLang = lang || i18next.language || 'ru';
const config = LANG_CURRENCY_MAP[resolvedLang] || DEFAULT_CURRENCY;
const rubles = kopeks / 100;
let amount = kopeks / 100;
// Конвертация по курсу для не-рублёвых локалей. Без rates fallback на сырую сумму
// (поведение до фикса), чтобы первый рендер до загрузки курсов не отдавал NaN.
if (config.key && cachedExchangeRates) {
amount = currencyApi.convertFromRub(amount, config.key, cachedExchangeRates);
}
// Для IRR суммы большие — без дробной части.
const maximumFractionDigits = config.currency === 'IRR' ? 0 : 2;
try {
return new Intl.NumberFormat(config.locale, {
style: 'currency',
currency: config.currency,
maximumFractionDigits: 0,
}).format(rubles);
maximumFractionDigits,
}).format(amount);
} catch {
return `${Math.round(rubles)} ${config.symbol}`;
const rounded =
maximumFractionDigits === 0 ? Math.round(amount) : Math.round(amount * 100) / 100;
return `${rounded} ${config.symbol}`;
}
}