From 6ffd0ae824c3480beb4a2f6f6b2567953741f856 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 04:56:15 +0300 Subject: [PATCH] fix(format): convert price by exchange rate on landings and gift page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatPrice from utils/format.ts only swapped the currency symbol (220 ₽ -> ¥220) without converting the underlying amount, because it had no source of exchange rates and the landing/gift pages never called the useCurrency hook that knew how to fetch them. - Add a module-level rates cache in utils/format with setExchangeRates setter. formatPrice now converts kopeks/100 from RUB to the target currency via currencyApi.convertFromRub when rates are cached. - useCurrency pushes its loaded rates into that cache so any subsequent formatPrice call benefits, including subcomponents that cannot easily receive a prop. - Call useCurrency in QuickPurchase, GiftSubscription, and AdminLandingEditor — the only entry points whose subcomponents still use the synchronous formatPrice (everything else already routes through useCurrency.formatAmount). For RU locale behavior is unchanged. For EN/ZH/FA the amount is now divided by the per-currency rate and formatted via Intl.NumberFormat with 2 fraction digits (0 for IRR since amounts are large). --- src/hooks/useCurrency.ts | 9 ++++++- src/pages/AdminLandingEditor.tsx | 4 ++++ src/pages/GiftSubscription.tsx | 4 ++++ src/pages/QuickPurchase.tsx | 5 ++++ src/utils/format.ts | 41 +++++++++++++++++++++++++------- 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts index c58462d..b7ed8e7 100644 --- a/src/hooks/useCurrency.ts +++ b/src/hooks/useCurrency.ts @@ -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 = { @@ -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'; diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index ef9f395..a4d18a7 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -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>({ general: true, diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index b929905..df6c3b5 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -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'); diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 3288833..1742189 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -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, diff --git a/src/utils/format.ts b/src/utils/format.ts index a116038..459f264 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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 = { +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}`; } }