fix(format): convert price by exchange rate on landings and gift page

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).
This commit is contained in:
Fringg
2026-05-13 04:56:15 +03:00
parent 1fdafbd0a1
commit 6ffd0ae824
5 changed files with 54 additions and 9 deletions

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}`;
}
}