mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
@@ -228,7 +228,11 @@ export default function SubscriptionCardExpired({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
<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>
|
||||||
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
|
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { initDataUser } from '@telegram-apps/sdk-react';
|
import { initDataUser } from '@telegram-apps/sdk-react';
|
||||||
|
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { displayName } from '@/utils/displayName';
|
||||||
import { useShallow } from 'zustand/shallow';
|
import { useShallow } from 'zustand/shallow';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { usePlatform } from '@/platform';
|
import { usePlatform } from '@/platform';
|
||||||
@@ -340,9 +341,7 @@ export function AppHeader({
|
|||||||
<UserIcon className="h-5 w-5" />
|
<UserIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-dark-100">
|
<div className="text-sm font-medium text-dark-100">{displayName(user)}</div>
|
||||||
{user?.first_name || user?.username}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
|
import { displayName } from '@/utils/displayName';
|
||||||
import {
|
import {
|
||||||
brandingApi,
|
brandingApi,
|
||||||
getCachedBranding,
|
getCachedBranding,
|
||||||
@@ -188,9 +189,7 @@ export function DesktopSidebar({
|
|||||||
<UserIcon className="h-4 w-4 text-dark-400" />
|
<UserIcon className="h-4 w-4 text-dark-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-dark-100">
|
<p className="truncate text-sm font-medium text-dark-100">{displayName(user)}</p>
|
||||||
{user?.first_name || user?.username || `#${user?.telegram_id}`}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-dark-500">
|
<p className="truncate text-xs text-dark-500">
|
||||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -136,9 +136,10 @@ function syncYandexCid(counterId: string) {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
// Route through brandingApi (apiClient) so baseURL, auth refresh, and
|
// Route through brandingApi (apiClient) so baseURL, auth refresh, and
|
||||||
// error handling all flow through the same interceptors as every other
|
// error handling all flow through the same interceptors as every other
|
||||||
// cabinet API call.
|
// cabinet API call. brandingApi уже импортирован статически — динамический
|
||||||
import('../api/branding').then(({ brandingApi: api }) => {
|
// import('../api/branding') ломал code-splitting (Vite warning о том, что
|
||||||
api
|
// модуль не может быть вынесен в отдельный chunk при mixed static+dynamic).
|
||||||
|
brandingApi
|
||||||
.storeYandexCid(cid)
|
.storeYandexCid(cid)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
try {
|
try {
|
||||||
@@ -151,7 +152,6 @@ function syncYandexCid(counterId: string) {
|
|||||||
/* swallow -- non-critical, will retry on next login */
|
/* swallow -- non-critical, will retry on next login */
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1515,6 +1515,8 @@
|
|||||||
"noMethods": "No payment methods configured",
|
"noMethods": "No payment methods configured",
|
||||||
"methodEnabled": "Method enabled",
|
"methodEnabled": "Method enabled",
|
||||||
"providerNotConfigured": "Provider not configured in env",
|
"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",
|
"displayName": "Display name",
|
||||||
"displayNameHint": "Leave empty for default name",
|
"displayNameHint": "Leave empty for default name",
|
||||||
"subOptions": "Payment options",
|
"subOptions": "Payment options",
|
||||||
|
|||||||
@@ -2082,6 +2082,8 @@
|
|||||||
"noMethods": "هیچ روش پرداخت پیکربندیشدهای نیست",
|
"noMethods": "هیچ روش پرداخت پیکربندیشدهای نیست",
|
||||||
"methodEnabled": "روش فعال است",
|
"methodEnabled": "روش فعال است",
|
||||||
"providerNotConfigured": "ارائهدهنده در env تنظیم نشده",
|
"providerNotConfigured": "ارائهدهنده در env تنظیم نشده",
|
||||||
|
"openUrlDirect": "صفحه پرداخت را مستقیماً باز کن",
|
||||||
|
"openUrlDirectHint": "بدون پنل لینک — ارائهدهنده داخل MiniApp/تب بلافاصله پس از کلیک باز میشود. پس از پرداخت کاربر به /balance/top-up/result بازمیگردد. برای Stars/CryptoBot و سایر لینکهای t.me/ این پرچم نادیده گرفته میشود.",
|
||||||
"displayName": "نام نمایشی",
|
"displayName": "نام نمایشی",
|
||||||
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
"displayNameHint": "برای نام پیشفرض خالی بگذارید",
|
||||||
"subOptions": "گزینههای پرداخت",
|
"subOptions": "گزینههای پرداخت",
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"traffic": "Трафик",
|
"traffic": "Трафик",
|
||||||
"devices": "Устройства",
|
"devices": "Устройства",
|
||||||
"expiredDate": "Истекла",
|
"expiredDate": "Истекла",
|
||||||
|
"expiredDate_trial": "Истёк",
|
||||||
"activeUntil": "Активна до"
|
"activeUntil": "Активна до"
|
||||||
},
|
},
|
||||||
"suspended": {
|
"suspended": {
|
||||||
@@ -1536,6 +1537,8 @@
|
|||||||
"noMethods": "Нет настроенных платёжных методов",
|
"noMethods": "Нет настроенных платёжных методов",
|
||||||
"methodEnabled": "Метод включён",
|
"methodEnabled": "Метод включён",
|
||||||
"providerNotConfigured": "Провайдер не настроен в env",
|
"providerNotConfigured": "Провайдер не настроен в env",
|
||||||
|
"openUrlDirect": "Открывать страницу оплаты сразу",
|
||||||
|
"openUrlDirectHint": "Без панели со ссылкой — провайдер открывается внутри MiniApp/вкладки сразу после клика. После оплаты юзер возвращается на /balance/top-up/result. Для Stars/CryptoBot и других t.me/-ссылок флаг игнорируется.",
|
||||||
"displayName": "Отображаемое имя",
|
"displayName": "Отображаемое имя",
|
||||||
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
"displayNameHint": "Оставьте пустым для имени по умолчанию",
|
||||||
"subOptions": "Варианты оплаты",
|
"subOptions": "Варианты оплаты",
|
||||||
|
|||||||
@@ -1280,6 +1280,8 @@
|
|||||||
"noMethods": "没有配置的支付方法",
|
"noMethods": "没有配置的支付方法",
|
||||||
"methodEnabled": "方法已启用",
|
"methodEnabled": "方法已启用",
|
||||||
"providerNotConfigured": "提供商未在env中配置",
|
"providerNotConfigured": "提供商未在env中配置",
|
||||||
|
"openUrlDirect": "直接打开支付页面",
|
||||||
|
"openUrlDirectHint": "跳过链接面板 — 点击后提供商在 MiniApp/标签页内直接打开。支付完成后用户返回 /balance/top-up/result。Stars/CryptoBot 和其他 t.me/ 链接忽略此标志。",
|
||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"displayNameHint": "留空以使用默认名称",
|
"displayNameHint": "留空以使用默认名称",
|
||||||
"subOptions": "支付选项",
|
"subOptions": "支付选项",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
|
const [firstTopupFilter, setFirstTopupFilter] = useState<'any' | 'yes' | 'no'>('any');
|
||||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
|
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
|
||||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
|
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
|
||||||
|
const [openUrlDirect, setOpenUrlDirect] = useState(false);
|
||||||
|
|
||||||
// Initialize state when config loads
|
// Initialize state when config loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,6 +80,8 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
setFirstTopupFilter(config.first_topup_filter);
|
setFirstTopupFilter(config.first_topup_filter);
|
||||||
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
setPromoGroupFilterMode(config.promo_group_filter_mode);
|
||||||
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
|
||||||
|
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
|
||||||
|
setOpenUrlDirect(config.open_url_direct ?? false);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
@@ -100,6 +103,7 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
first_topup_filter: firstTopupFilter,
|
first_topup_filter: firstTopupFilter,
|
||||||
promo_group_filter_mode: promoGroupFilterMode,
|
promo_group_filter_mode: promoGroupFilterMode,
|
||||||
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
||||||
|
open_url_direct: openUrlDirect,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Display name
|
// Display name
|
||||||
@@ -215,6 +219,34 @@ export default function AdminPaymentMethodEdit() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Display name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
<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 { Link, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { displayName } from '../utils/displayName';
|
||||||
import { useBlockingStore } from '../store/blocking';
|
import { useBlockingStore } from '../store/blocking';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
@@ -195,7 +196,12 @@ export default function Dashboard() {
|
|||||||
refreshTrafficMutation.mutate();
|
refreshTrafficMutation.mutate();
|
||||||
}, [subscription, refreshTrafficMutation]);
|
}, [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
|
// Show onboarding for new users after data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -249,7 +255,7 @@ export default function Dashboard() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div data-onboarding="welcome">
|
<div data-onboarding="welcome">
|
||||||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
<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>
|
</h1>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<p className="text-dark-400">{t('dashboard.yourSubscription')}</p>
|
<p className="text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePlatform } from '@/platform';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { displayName } from '../utils/displayName';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { isValidEmail } from '../utils/validation';
|
import { isValidEmail } from '../utils/validation';
|
||||||
import {
|
import {
|
||||||
@@ -333,9 +334,7 @@ export default function Profile() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between border-b border-dark-800/50 py-3">
|
<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="text-dark-400">{t('profile.name')}</span>
|
||||||
<span className="font-medium text-dark-100">
|
<span className="font-medium text-dark-100">{displayName(user)}</span>
|
||||||
{user?.first_name} {user?.last_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<div className="flex items-center justify-between py-3">
|
||||||
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
<span className="text-dark-400">{t('profile.registeredAt')}</span>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1576,10 +1576,11 @@ export default function Subscription() {
|
|||||||
<div className="mb-2 text-sm text-dark-400">
|
<div className="mb-2 text-sm text-dark-400">
|
||||||
{/* Show original price with strikethrough if discount */}
|
{/* Show original price with strikethrough if discount */}
|
||||||
{devicePriceData.discount_percent &&
|
{devicePriceData.discount_percent &&
|
||||||
devicePriceData.discount_percent > 0 ? (
|
devicePriceData.discount_percent > 0 &&
|
||||||
|
devicePriceData.original_price_per_device_kopeks ? (
|
||||||
<span>
|
<span>
|
||||||
<span className="text-dark-500 line-through">
|
<span className="text-dark-500 line-through">
|
||||||
{formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
|
{formatPrice(devicePriceData.original_price_per_device_kopeks)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
|
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
|
||||||
</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 { Navigate, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
|
import { balanceApi } from '../api/balance';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
import { getGlassColors } from '../utils/glassTheme';
|
import { getGlassColors } from '../utils/glassTheme';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||||
|
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||||
|
|
||||||
function EmptyState({ onBuy }: { onBuy: () => void }) {
|
function EmptyState({ onBuy }: { onBuy: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -55,6 +59,9 @@ export default function Subscriptions() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const g = getGlassColors(isDark);
|
const g = getGlassColors(isDark);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
|
const [trialError, setTrialError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['subscriptions-list'],
|
queryKey: ['subscriptions-list'],
|
||||||
@@ -65,6 +72,39 @@ export default function Subscriptions() {
|
|||||||
|
|
||||||
const subscriptions = data?.subscriptions ?? [];
|
const subscriptions = data?.subscriptions ?? [];
|
||||||
const isMultiTariff = data?.multi_tariff_enabled ?? false;
|
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
|
// Single-tariff mode with one subscription: skip list, go directly to detail
|
||||||
if (data && !isMultiTariff && subscriptions.length === 1) {
|
if (data && !isMultiTariff && subscriptions.length === 1) {
|
||||||
@@ -115,8 +155,17 @@ export default function Subscriptions() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state: показываем триал, если доступен; иначе — обычный empty */}
|
||||||
{!isLoading && subscriptions.length === 0 && (
|
{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')} />
|
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ export default function TopUpAmount() {
|
|||||||
: converted.toFixed(2);
|
: converted.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [amount, setAmount] = useState(getInitialAmount);
|
const initialDisplayAmount = getInitialAmount();
|
||||||
|
const [amount, setAmount] = useState(initialDisplayAmount);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||||
getPreferredOptionId(method?.options),
|
getPreferredOptionId(method?.options),
|
||||||
@@ -254,9 +255,8 @@ export default function TopUpAmount() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const redirectUrl = data.payment_url || data.invoice_url;
|
const redirectUrl = data.payment_url || data.invoice_url;
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
setPaymentUrl(redirectUrl);
|
// Save payment info for the result page (do BEFORE possible redirect,
|
||||||
|
// иначе после window.location.href этот код не выполнится).
|
||||||
// Save payment info for the result page
|
|
||||||
if (method && data.payment_id) {
|
if (method && data.payment_id) {
|
||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||||
const displayName =
|
const displayName =
|
||||||
@@ -269,6 +269,29 @@ export default function TopUpAmount() {
|
|||||||
created_at: Date.now(),
|
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) => {
|
onError: (err: unknown) => {
|
||||||
@@ -334,7 +357,22 @@ export default function TopUpAmount() {
|
|||||||
return;
|
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) {
|
if (isStarsMethod) {
|
||||||
starsPaymentMutation.mutate(amountKopeks);
|
starsPaymentMutation.mutate(amountKopeks);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -442,6 +442,9 @@ export interface PaymentMethod {
|
|||||||
max_amount_kopeks: number;
|
max_amount_kopeks: number;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
options?: PaymentMethodOption[] | null;
|
options?: PaymentMethodOption[] | null;
|
||||||
|
// Если true — после получения payment_url кабинет сразу делает
|
||||||
|
// window.location.href вместо показа панели с кнопкой "Открыть".
|
||||||
|
open_url_direct?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Referral types
|
// Referral types
|
||||||
@@ -682,6 +685,7 @@ export interface PaymentMethodConfig {
|
|||||||
first_topup_filter: 'any' | 'yes' | 'no';
|
first_topup_filter: 'any' | 'yes' | 'no';
|
||||||
promo_group_filter_mode: 'all' | 'selected';
|
promo_group_filter_mode: 'all' | 'selected';
|
||||||
allowed_promo_group_ids: number[];
|
allowed_promo_group_ids: number[];
|
||||||
|
open_url_direct: boolean;
|
||||||
is_provider_configured: boolean;
|
is_provider_configured: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_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 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