mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { currencyApi, type ExchangeRates } from '../api/currency';
|
import { currencyApi, type ExchangeRates } from '../api/currency';
|
||||||
|
import { setExchangeRates as setGlobalExchangeRates } from '../utils/format';
|
||||||
|
|
||||||
// Map language to currency
|
// Map language to currency
|
||||||
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||||
@@ -30,6 +31,12 @@ export function useCurrency() {
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Делимся курсом с синхронным formatPrice из utils/format.ts —
|
||||||
|
// у него нет своего источника rates, а на лендинге его дёргают subcomponents.
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalExchangeRates(exchangeRates);
|
||||||
|
}, [exchangeRates]);
|
||||||
|
|
||||||
// Get current language and currency
|
// Get current language and currency
|
||||||
const currentLanguage = i18n.language;
|
const currentLanguage = i18n.language;
|
||||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
|
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../api/landings';
|
} from '../api/landings';
|
||||||
import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
|
import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
|
||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
|
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
|
||||||
import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor';
|
import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor';
|
||||||
@@ -97,6 +98,9 @@ export default function AdminLandingEditor() {
|
|||||||
const { capabilities } = usePlatform();
|
const { capabilities } = usePlatform();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
// Прогреваем кэш курсов валют для formatPrice (preview лендинга в не-RU локали).
|
||||||
|
useCurrency();
|
||||||
|
|
||||||
// Section visibility
|
// Section visibility
|
||||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||||
general: true,
|
general: true,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
import { getApiErrorMessage } from '../utils/api-error';
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { usePlatform, useHaptic } from '@/platform';
|
import { usePlatform, useHaptic } from '@/platform';
|
||||||
|
|
||||||
function GiftIcon({ className }: { className?: string }) {
|
function GiftIcon({ className }: { className?: string }) {
|
||||||
@@ -1286,6 +1287,9 @@ export default function GiftSubscription() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Прогреваем кэш курсов валют для formatPrice (см. QuickPurchase).
|
||||||
|
useCurrency();
|
||||||
|
|
||||||
// URL params: ?tab=activate&code=TOKEN for auto-activation
|
// URL params: ?tab=activate&code=TOKEN for auto-activation
|
||||||
const urlTab = searchParams.get('tab') as TabId | null;
|
const urlTab = searchParams.get('tab') as TabId | null;
|
||||||
const rawCode = searchParams.get('code');
|
const rawCode = searchParams.get('code');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
|
|||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { getApiErrorMessage } from '../utils/api-error';
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
|
||||||
function detectContactType(value: string): 'email' | 'telegram' {
|
function detectContactType(value: string): 'email' | 'telegram' {
|
||||||
return value.startsWith('@') ? 'telegram' : 'email';
|
return value.startsWith('@') ? 'telegram' : 'email';
|
||||||
@@ -795,6 +796,10 @@ export default function QuickPurchase() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Подгружаем курсы валют, чтобы formatPrice конвертировал суммы для не-RU локалей
|
||||||
|
// (хук кладёт rates в глобальный кэш utils/format.ts).
|
||||||
|
useCurrency();
|
||||||
|
|
||||||
// Fetch config
|
// Fetch config
|
||||||
const {
|
const {
|
||||||
data: config,
|
data: config,
|
||||||
|
|||||||
@@ -10,27 +10,52 @@ export function formatUptime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import i18next from 'i18next';
|
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: '₽' },
|
ru: { currency: 'RUB', locale: 'ru-RU', symbol: '₽' },
|
||||||
en: { currency: 'USD', locale: 'en-US', symbol: '$' },
|
en: { currency: 'USD', locale: 'en-US', symbol: '$', key: 'USD' },
|
||||||
zh: { currency: 'CNY', locale: 'zh-CN', symbol: '¥' },
|
zh: { currency: 'CNY', locale: 'zh-CN', symbol: '¥', key: 'CNY' },
|
||||||
fa: { currency: 'IRR', locale: 'fa-IR', symbol: '﷼' },
|
fa: { currency: 'IRR', locale: 'fa-IR', symbol: '﷼', key: 'IRR' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CURRENCY = { currency: 'RUB', locale: 'ru-RU', symbol: '₽' };
|
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 {
|
export function formatPrice(kopeks: number, lang?: string): string {
|
||||||
const resolvedLang = lang || i18next.language || 'ru';
|
const resolvedLang = lang || i18next.language || 'ru';
|
||||||
const config = LANG_CURRENCY_MAP[resolvedLang] || DEFAULT_CURRENCY;
|
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 {
|
try {
|
||||||
return new Intl.NumberFormat(config.locale, {
|
return new Intl.NumberFormat(config.locale, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: config.currency,
|
currency: config.currency,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits,
|
||||||
}).format(rubles);
|
}).format(amount);
|
||||||
} catch {
|
} catch {
|
||||||
return `${Math.round(rubles)} ${config.symbol}`;
|
const rounded =
|
||||||
|
maximumFractionDigits === 0 ? Math.round(amount) : Math.round(amount * 100) / 100;
|
||||||
|
return `${rounded} ${config.symbol}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user