From 1fdafbd0a1a5dbfc34b2ea9044d248487278a335 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 10 May 2026 09:59:57 +0300 Subject: [PATCH 1/9] fix(subscription): hide strikethrough free label on device addon price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-device price line in the buy-devices modal showed a strikethrough 'Бесплатно' for users with a promo-group discount, because original_price_per_device_kopeks was missing in the API response and formatPrice(0) renders 'Бесплатно'. Now the strikethrough is rendered only when original_price_per_device_kopeks is present and non-zero, mirroring the existing guard on the total price block. --- src/pages/Subscription.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 9fafe73..4e367cf 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1576,10 +1576,11 @@ export default function Subscription() {
{/* Show original price with strikethrough if discount */} {devicePriceData.discount_percent && - devicePriceData.discount_percent > 0 ? ( + devicePriceData.discount_percent > 0 && + devicePriceData.original_price_per_device_kopeks ? ( - {formatPrice(devicePriceData.original_price_per_device_kopeks || 0)} + {formatPrice(devicePriceData.original_price_per_device_kopeks)} {devicePriceData.price_per_device_label} From 6ffd0ae824c3480beb4a2f6f6b2567953741f856 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 04:56:15 +0300 Subject: [PATCH 2/9] 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}`; } } From 7c10843d9c318c82c8525949c40ab94861041039 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 05:31:22 +0300 Subject: [PATCH 3/9] fix(cabinet): show trial offer in multi-tariff mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cabinet routes never showed TrialOfferCard for users without any subscription when MULTI_TARIFF was enabled: 1. Dashboard (/) hasNoSubscription was derived from subscriptionResponse, but the /cabinet/subscription query is disabled in multi-tariff mode (`enabled: !isMultiTariff`). subscriptionResponse stayed undefined forever, so `has_subscription === false` was never true and the trial card never rendered. Fix: branch on isMultiTariff — use multiSubData.subscriptions.length when in multi-tariff, keep the single-tariff path unchanged. 2. /subscriptions list When the array came back empty the page rendered EmptyState directly, bypassing trial eligibility entirely. Users who navigated to 'Подписки' from the menu before opening the dashboard saw the empty placeholder and nothing about the trial. Fix: fetch trial-info on empty state, render TrialOfferCard if trialInfo.is_available, fall back to EmptyState otherwise. Also wired the activate mutation locally (mirrors Dashboard) so the button works without bouncing through the home screen first. Single-tariff behavior is unchanged. After the fix both entry points agree: if you have no subscriptions and trial is available, you see the offer regardless of which page you opened first. --- src/pages/Dashboard.tsx | 7 ++++- src/pages/Subscriptions.tsx | 55 +++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 5fa39d9..b04cde4 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -195,7 +195,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(() => { diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 1b2e600..9fb032f 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -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(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() {
)} - {/* Empty state */} - {!isLoading && subscriptions.length === 0 && ( + {/* Empty state: показываем триал, если доступен; иначе — обычный empty */} + {hasNoSubscriptions && !trialLoading && trialInfo?.is_available && ( + + )} + {hasNoSubscriptions && !trialLoading && !trialInfo?.is_available && ( navigate('/subscription/purchase')} /> )} From 4de01ccd1df913510aab2368552e6f993e7aa25f Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 09:02:10 +0300 Subject: [PATCH 4/9] fix(dashboard): show full name on welcome, fix gender mismatch on trial-expired card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported single-letter welcome "Добро пожаловать, О!" (short first_name "Олег"-style users) and a confused "Истекла" feminine label on the masculine "Пробный период истёк" trial-expired card. * Add `src/utils/displayName.ts` helper that composes `first_name + last_name` with `username` and `#telegram_id` fallbacks. Single source of truth for user-facing name rendering across the app. * Apply `displayName(user)` in Dashboard welcome, AppHeader mobile drawer, and DesktopSidebar profile chip. Now a user with `first_name="О"` and `last_name="Иванов"` sees "О Иванов" instead of just "О". * `SubscriptionCardExpired` — context-aware Russian label: for trial subscriptions render masculine "Истёк" (agrees with "пробный период"), for paid subscriptions keep feminine "Истекла" (agrees with "подписка"). Uses i18next `context: subscription.is_trial ? 'trial' : ''` — falls back to base key for EN/ZH/FA which are grammatically neutral. * Add `expiredDate_trial: "Истёк"` only to `ru.json` (no changes needed for other locales — i18next context falls back to `expiredDate`). --- .../dashboard/SubscriptionCardExpired.tsx | 6 ++++- src/components/layout/AppShell/AppHeader.tsx | 5 ++-- .../layout/AppShell/DesktopSidebar.tsx | 5 ++-- src/locales/ru.json | 1 + src/pages/Dashboard.tsx | 3 ++- src/utils/displayName.ts | 23 +++++++++++++++++++ 6 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 src/utils/displayName.ts diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 361e727..4709ded 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -228,7 +228,11 @@ export default function SubscriptionCardExpired({ >
- {isLimited ? t('dashboard.expired.activeUntil') : t('dashboard.expired.expiredDate')} + {isLimited + ? t('dashboard.expired.activeUntil') + : t('dashboard.expired.expiredDate', { + context: subscription.is_trial ? 'trial' : '', + })}
{formattedDate} diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 7d2c89f..3cac5df 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -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({
-
- {user?.first_name || user?.username} -
+
{displayName(user)}
@{user?.username || `ID: ${user?.telegram_id}`}
diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index 7799435..99b9ad9 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -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({
-

- {user?.first_name || user?.username || `#${user?.telegram_id}`} -

+

{displayName(user)}

@{user?.username || `ID: ${user?.telegram_id}`}

diff --git a/src/locales/ru.json b/src/locales/ru.json index 22b67af..be768f7 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -285,6 +285,7 @@ "traffic": "Трафик", "devices": "Устройства", "expiredDate": "Истекла", + "expiredDate_trial": "Истёк", "activeUntil": "Активна до" }, "suspended": { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index b04cde4..66ae194 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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'; @@ -254,7 +255,7 @@ export default function Dashboard() { {/* Header */}

- {t('dashboard.welcome', { name: user?.first_name || user?.username || '' })} + {t('dashboard.welcome', { name: displayName(user) })}

{t('dashboard.yourSubscription')}

diff --git a/src/utils/displayName.ts b/src/utils/displayName.ts new file mode 100644 index 0000000..d79412f --- /dev/null +++ b/src/utils/displayName.ts @@ -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 ''; +} From 172850e13a83f0ee57225985b46ffda84b4ba1a4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 09:20:48 +0300 Subject: [PATCH 5/9] fix(profile): use displayName helper for name field Follow-up to displayName migration. Profile page still rendered `{user?.first_name} {user?.last_name}` directly, leaving a trailing space when last_name is null. displayName() handles the joining and fallback uniformly. --- src/pages/Profile.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 953bc49..9da7cad 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -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() { )}
{t('profile.name')} - - {user?.first_name} {user?.last_name} - + {displayName(user)}
{t('profile.registeredAt')} From 2a342f6adc9ee4e2d0c157b69edc7213e1dc2d1b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 10:24:08 +0300 Subject: [PATCH 6/9] fix(topup): preserve canonical RUB amount to avoid FX round-trip shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: a user in EN locale paid 1.65 USD for a 150₽ subscription, but the bot received 14960 kopeks (149.60₽) — short by 40 kopeks, blocking the subscription purchase. Root cause: TopUpAmount.tsx displays the prefilled RUB amount converted via `.toFixed(2)`. With an exchange rate like 90.66 RUB/USD, 150₽ → 1.6545 USD → displayed "1.65" (rounded down by 0.0045 USD ≈ 0.4 RUB). When the user submits without editing, `convertToRub("1.65")` runs again and returns 1.65 × 90.66 = 149.589₽ — less than the 150₽ the user is trying to pay. Math.round/Math.ceil on this only handles sub-kopek IEEE-754 fractions, not the deeper FX display- rounding direction. Fix: when the user does not edit the prefilled amount (`amount === initialDisplayAmount`) and `initialAmountRubles > 0` is known, bypass the FX round-trip and send `Math.round(initialAmountRubles * 100)` directly. Math.ceil for non-RUB targets when the user does type a custom amount still helps with sub-kopek fractions. `?amount=150` from a renew CTA → "1.65 USD" displayed → user clicks pay → bot receives 15000 kopeks instead of 14960. Subscription renews as expected. --- src/pages/TopUpAmount.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 0c470bf..92f6952 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -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(null); const [selectedOption, setSelectedOption] = useState( getPreferredOptionId(method?.options), @@ -334,7 +335,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 { From aa8bfc9d0859bcbe31de60f7127e5e606e146316 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 10:50:31 +0300 Subject: [PATCH 7/9] feat(topup): direct-open payment page when method.open_url_direct is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked for the gift-style seamless flow on balance top-up: provider checkout opens inside Telegram MiniApp WebView without a click-to-open link panel. Made it an admin per-method toggle so it can be enabled selectively. src/pages/TopUpAmount.tsx — on topup mutation success: * Move saveTopUpPendingInfo to BEFORE any redirect so /balance/top-up/result can still pick up the pending payment after the provider's return_url fires * If method?.open_url_direct === true AND the URL is not a t.me/ deep link (Stars/CryptoBot), window.location.href = redirectUrl and return early * Otherwise fall through to the existing setPaymentUrl panel — preserves current behavior for methods without the flag enabled The t.me/ guard is important: window.location.href to a Telegram deep link inside a MiniApp WebView is unreliable (native shell cant always intercept). Those URLs continue to go through openTelegramLink / openInvoice in the panel path. Stars never reaches topUpMutation.onSuccess anyway (handled by the separate starsPaymentMutation); the guard is defense-in-depth for CryptoBot and any future t.me-deep-link providers. src/pages/AdminPaymentMethodEdit.tsx — added Open URL directly toggle with the same slider styling as the is_enabled toggle. Defaults to off. src/types/index.ts * PaymentMethod.open_url_direct?: boolean (user-facing) * PaymentMethodConfig.open_url_direct: boolean (admin shape) Translations added for ru/en/zh/fa with a hint clarifying behavior and the t.me/ exemption. --- src/locales/en.json | 2 ++ src/locales/fa.json | 2 ++ src/locales/ru.json | 2 ++ src/locales/zh.json | 2 ++ src/pages/AdminPaymentMethodEdit.tsx | 31 ++++++++++++++++++++++++++++ src/pages/TopUpAmount.tsx | 21 ++++++++++++++++--- src/types/index.ts | 4 ++++ 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 2af6e2e..dbb137c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 854ed89..c26fb6d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2082,6 +2082,8 @@ "noMethods": "هیچ روش پرداخت پیکربندی‌شده‌ای نیست", "methodEnabled": "روش فعال است", "providerNotConfigured": "ارائه‌دهنده در env تنظیم نشده", + "openUrlDirect": "صفحه پرداخت را مستقیماً باز کن", + "openUrlDirectHint": "بدون پنل لینک — ارائه‌دهنده داخل MiniApp/تب بلافاصله پس از کلیک باز می‌شود. پس از پرداخت کاربر به /balance/top-up/result بازمی‌گردد. برای Stars/CryptoBot و سایر لینک‌های t.me/ این پرچم نادیده گرفته می‌شود.", "displayName": "نام نمایشی", "displayNameHint": "برای نام پیش‌فرض خالی بگذارید", "subOptions": "گزینه‌های پرداخت", diff --git a/src/locales/ru.json b/src/locales/ru.json index be768f7..394b2a0 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1537,6 +1537,8 @@ "noMethods": "Нет настроенных платёжных методов", "methodEnabled": "Метод включён", "providerNotConfigured": "Провайдер не настроен в env", + "openUrlDirect": "Открывать страницу оплаты сразу", + "openUrlDirectHint": "Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.", "displayName": "Отображаемое имя", "displayNameHint": "Оставьте пустым для имени по умолчанию", "subOptions": "Варианты оплаты", diff --git a/src/locales/zh.json b/src/locales/zh.json index 38ad838..dcac98d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1280,6 +1280,8 @@ "noMethods": "没有配置的支付方法", "methodEnabled": "方法已启用", "providerNotConfigured": "提供商未在env中配置", + "openUrlDirect": "直接打开支付页面", + "openUrlDirectHint": "跳过链接面板 — 点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。", "displayName": "显示名称", "displayNameHint": "留空以使用默认名称", "subOptions": "支付选项", diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index 2f76918..f453f6d 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -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([]); + const [openUrlDirect, setOpenUrlDirect] = useState(false); // Initialize state when config loads useEffect(() => { @@ -79,6 +80,7 @@ export default function AdminPaymentMethodEdit() { setFirstTopupFilter(config.first_topup_filter); setPromoGroupFilterMode(config.promo_group_filter_mode); setSelectedPromoGroupIds(config.allowed_promo_group_ids); + setOpenUrlDirect(config.open_url_direct); } }, [config]); @@ -100,6 +102,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 +218,34 @@ export default function AdminPaymentMethodEdit() {
+ {/* Open URL directly toggle */} +
+
+
+ {t('admin.paymentMethods.openUrlDirect', 'Открывать страницу оплаты сразу')} +
+
+ {t( + 'admin.paymentMethods.openUrlDirectHint', + 'Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/result.', + )} +
+
+ +
+ {/* Display name */}