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/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.registeredAt')}
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/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}
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')} />
)}
diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx
index 0c470bf..a846585 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),
@@ -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 {
diff --git a/src/types/index.ts b/src/types/index.ts
index ad6bc2c..5b1abde 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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;
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 '';
+}
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}`;
}
}