mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -228,7 +228,11 @@ export default function SubscriptionCardExpired({
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
||||
{isLimited ? t('dashboard.expired.activeUntil') : t('dashboard.expired.expiredDate')}
|
||||
{isLimited
|
||||
? t('dashboard.expired.activeUntil')
|
||||
: t('dashboard.expired.expiredDate', {
|
||||
context: subscription.is_trial ? 'trial' : '',
|
||||
})}
|
||||
</div>
|
||||
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
|
||||
{formattedDate}
|
||||
|
||||
@@ -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({
|
||||
<UserIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{user?.first_name || user?.username}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-100">{displayName(user)}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<UserIcon className="h-4 w-4 text-dark-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-dark-100">
|
||||
{user?.first_name || user?.username || `#${user?.telegram_id}`}
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-dark-100">{displayName(user)}</p>
|
||||
<p className="truncate text-xs text-dark-500">
|
||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||
</p>
|
||||
|
||||
@@ -136,21 +136,21 @@ function syncYandexCid(counterId: string) {
|
||||
if (!token) return;
|
||||
// Route through brandingApi (apiClient) so baseURL, auth refresh, and
|
||||
// error handling all flow through the same interceptors as every other
|
||||
// cabinet API call.
|
||||
import('../api/branding').then(({ brandingApi: api }) => {
|
||||
api
|
||||
.storeYandexCid(cid)
|
||||
.then(() => {
|
||||
try {
|
||||
localStorage.setItem(SENT_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow -- non-critical, will retry on next login */
|
||||
});
|
||||
});
|
||||
// cabinet API call. brandingApi уже импортирован статически — динамический
|
||||
// import('../api/branding') ломал code-splitting (Vite warning о том, что
|
||||
// модуль не может быть вынесен в отдельный chunk при mixed static+dynamic).
|
||||
brandingApi
|
||||
.storeYandexCid(cid)
|
||||
.then(() => {
|
||||
try {
|
||||
localStorage.setItem(SENT_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow -- non-critical, will retry on next login */
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -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<string, keyof ExchangeRates | 'RUB'> = {
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2082,6 +2082,8 @@
|
||||
"noMethods": "هیچ روش پرداخت پیکربندیشدهای نیست",
|
||||
"methodEnabled": "روش فعال است",
|
||||
"providerNotConfigured": "ارائهدهنده در env تنظیم نشده",
|
||||
"openUrlDirect": "صفحه پرداخت را مستقیماً باز کن",
|
||||
"openUrlDirectHint": "بدون پنل لینک — ارائهدهنده داخل MiniApp/تب بلافاصله پس از کلیک باز میشود. پس از پرداخت کاربر به /balance/top-up/result بازمیگردد. برای Stars/CryptoBot و سایر لینکهای t.me/ این پرچم نادیده گرفته میشود.",
|
||||
"displayName": "نام نمایشی",
|
||||
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
||||
"subOptions": "گزینههای پرداخت",
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"traffic": "Трафик",
|
||||
"devices": "Устройства",
|
||||
"expiredDate": "Истекла",
|
||||
"expiredDate_trial": "Истёк",
|
||||
"activeUntil": "Активна до"
|
||||
},
|
||||
"suspended": {
|
||||
@@ -1536,6 +1537,8 @@
|
||||
"noMethods": "Нет настроенных платёжных методов",
|
||||
"methodEnabled": "Метод включён",
|
||||
"providerNotConfigured": "Провайдер не настроен в env",
|
||||
"openUrlDirect": "Открывать страницу оплаты сразу",
|
||||
"openUrlDirectHint": "Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
|
||||
"displayName": "Отображаемое имя",
|
||||
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
||||
"subOptions": "Варианты оплаты",
|
||||
|
||||
@@ -1280,6 +1280,8 @@
|
||||
"noMethods": "没有配置的支付方法",
|
||||
"methodEnabled": "方法已启用",
|
||||
"providerNotConfigured": "提供商未在env中配置",
|
||||
"openUrlDirect": "直接打开支付页面",
|
||||
"openUrlDirectHint": "跳过链接面板 — 点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
|
||||
"displayName": "显示名称",
|
||||
"displayNameHint": "留空以使用默认名称",
|
||||
"subOptions": "支付选项",
|
||||
|
||||
@@ -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<Record<string, boolean>>({
|
||||
general: true,
|
||||
|
||||
@@ -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<number[]>([]);
|
||||
const [openUrlDirect, setOpenUrlDirect] = useState(false);
|
||||
|
||||
// Initialize state when config loads
|
||||
useEffect(() => {
|
||||
@@ -79,6 +80,8 @@ export default function AdminPaymentMethodEdit() {
|
||||
setFirstTopupFilter(config.first_topup_filter);
|
||||
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
||||
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||||
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
|
||||
setOpenUrlDirect(config.open_url_direct ?? false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
@@ -100,6 +103,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 +219,34 @@ export default function AdminPaymentMethodEdit() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open URL directly toggle */}
|
||||
<div className="flex items-center justify-between border-t border-dark-800 pt-6">
|
||||
<div className="pr-4">
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.openUrlDirect', 'Открывать страницу оплаты сразу')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{t(
|
||||
'admin.paymentMethods.openUrlDirectHint',
|
||||
'Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/result.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpenUrlDirect(!openUrlDirect)}
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||
openUrlDirect ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
aria-label={t('admin.paymentMethods.openUrlDirect', 'Open payment page directly')}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
openUrlDirect ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
|
||||
@@ -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';
|
||||
@@ -195,7 +196,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(() => {
|
||||
@@ -249,7 +255,7 @@ export default function Dashboard() {
|
||||
{/* Header */}
|
||||
<div data-onboarding="welcome">
|
||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||
{t('dashboard.welcome', { name: user?.first_name || user?.username || '' })}
|
||||
{t('dashboard.welcome', { name: displayName(user) })}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<p className="text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
||||
<span className="text-dark-400">{t('profile.name')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</span>
|
||||
<span className="font-medium text-dark-100">{displayName(user)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1576,10 +1576,11 @@ export default function Subscription() {
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{/* Show original price with strikethrough if discount */}
|
||||
{devicePriceData.discount_percent &&
|
||||
devicePriceData.discount_percent > 0 ? (
|
||||
devicePriceData.discount_percent > 0 &&
|
||||
devicePriceData.original_price_per_device_kopeks ? (
|
||||
<span>
|
||||
<span className="text-dark-500 line-through">
|
||||
{formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
|
||||
{formatPrice(devicePriceData.original_price_per_device_kopeks)}
|
||||
</span>
|
||||
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
|
||||
</span>
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && subscriptions.length === 0 && (
|
||||
{/* Empty state: показываем триал, если доступен; иначе — обычный empty */}
|
||||
{hasNoSubscriptions && !trialLoading && trialInfo?.is_available && (
|
||||
<TrialOfferCard
|
||||
trialInfo={trialInfo}
|
||||
balanceKopeks={balanceData?.balance_kopeks ?? 0}
|
||||
balanceRubles={balanceData?.balance_rubles ?? 0}
|
||||
activateTrialMutation={activateTrialMutation}
|
||||
trialError={trialError}
|
||||
/>
|
||||
)}
|
||||
{hasNoSubscriptions && !trialLoading && !trialInfo?.is_available && (
|
||||
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
src/utils/displayName.ts
Normal file
23
src/utils/displayName.ts
Normal file
@@ -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 '';
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user