diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts index d5360b2..2f0d0dd 100644 --- a/src/api/adminBroadcasts.ts +++ b/src/api/adminBroadcasts.ts @@ -48,6 +48,12 @@ export interface BroadcastButtonsResponse { buttons: BroadcastButton[]; } +export interface CustomBroadcastButton { + label: string; + action_type: 'callback' | 'url'; + action_value: string; +} + export interface BroadcastMedia { type: 'photo' | 'video' | 'document'; file_id: string; @@ -73,6 +79,7 @@ export interface CombinedBroadcastCreateRequest { // Telegram fields message_text?: string; selected_buttons?: string[]; + custom_buttons?: CustomBroadcastButton[]; media?: BroadcastMedia; // Email fields email_subject?: string; diff --git a/src/api/auth.ts b/src/api/auth.ts index d35b9d6..297fb77 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -290,7 +290,7 @@ export const authApi = { return response.data; }, - pollDeepLinkToken: async (token: string): Promise => { + pollDeepLinkToken: async (token: string, campaignSlug?: string | null): Promise => { // validateStatus: only treat 200 as success. // Server returns 202 for "pending" and 410 for "expired" — // these must reject so the polling catch-block can handle them. @@ -299,7 +299,7 @@ export const authApi = { // which triggers checkAdminStatus() → 401 → safeRedirectToLogin() → infinite reload. const response = await apiClient.post( '/cabinet/auth/deeplink/poll', - { token }, + { token, campaign_slug: campaignSlug || undefined }, { validateStatus: (status) => status === 200 }, ); return response.data; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 197015c..0bf5b1a 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -2,10 +2,13 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; +import { QRCodeSVG } from 'qrcode.react'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { useNavigate } from 'react-router'; +import { consumeCampaignSlug } from '../utils/campaign'; +import { copyToClipboard } from '../utils/clipboard'; interface TelegramLoginButtonProps { referralCode?: string; @@ -29,11 +32,18 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const [deepLinkBotUsername, setDeepLinkBotUsername] = useState(''); const [deepLinkPolling, setDeepLinkPolling] = useState(false); const [deepLinkError, setDeepLinkError] = useState(''); + const [copied, setCopied] = useState(false); const pollTimeoutRef = useRef>(null); const expireTimeoutRef = useRef>(null); + const copiedTimeoutRef = useRef>(null); + const pollInFlightRef = useRef(false); const loginWithDeepLink = useAuthStore((s) => s.loginWithDeepLink); + // Capture campaign slug once on mount (before any retry clears it) + const capturedCampaignRef = useRef(null); + const codesConsumedRef = useRef(false); + const { data: widgetConfig } = useQuery({ queryKey: ['telegram-widget-config'], queryFn: brandingApi.getTelegramWidgetConfig, @@ -56,15 +66,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto }; }, []); - // Cleanup polling and expire timeout on unmount + // Cleanup all timeouts on unmount useEffect(() => { return () => { - if (pollTimeoutRef.current) { - clearTimeout(pollTimeoutRef.current); - } - if (expireTimeoutRef.current) { - clearTimeout(expireTimeoutRef.current); - } + if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current); + if (expireTimeoutRef.current) clearTimeout(expireTimeoutRef.current); + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); }; }, []); @@ -228,7 +235,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const startDeepLinkAuth = useCallback(async () => { setDeepLinkError(''); - // Clear any previous timers + // Clear any previous timers and in-flight guard if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; @@ -237,8 +244,20 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto clearTimeout(expireTimeoutRef.current); expireTimeoutRef.current = null; } + pollInFlightRef.current = false; try { + // Consume campaign slug ONCE (first call only). + // Clears localStorage on first call, so subsequent retries reuse the ref. + // Note: referral code is NOT consumed here — deep link auth is for existing + // bot users where referrals don't apply. Leaving it in localStorage allows + // other auth methods (OIDC, widget) to pick it up if the user switches paths. + if (!codesConsumedRef.current) { + capturedCampaignRef.current = consumeCampaignSlug(); + codesConsumedRef.current = true; + } + const capturedCampaign = capturedCampaignRef.current; + const response = await authApi.requestDeepLinkToken(); const { token, bot_username, expires_in } = response; setDeepLinkToken(token); @@ -247,9 +266,11 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto // Recursive setTimeout prevents overlapping async calls const poll = async () => { - if (!mountedRef.current) return; + if (!mountedRef.current || pollInFlightRef.current) return; + pollInFlightRef.current = true; try { - await loginWithDeepLink(token); + // Deep link auth is for existing bot users — only campaign_slug applies + await loginWithDeepLink(token, capturedCampaign); // Success — auth store is updated, navigate if (expireTimeoutRef.current) { clearTimeout(expireTimeoutRef.current); @@ -268,16 +289,26 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto return; } if (err.response?.status === 410) { - // Token expired + // Token expired — clear expire timer to prevent stale timer killing future sessions + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkToken(null); setDeepLinkError(t('auth.deepLinkExpired')); return; } } - // Other error — stop polling + // Other error — stop polling and clear expire timer + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkError(t('common.error')); + } finally { + pollInFlightRef.current = false; } }; @@ -318,6 +349,77 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); + // Resume polling immediately when user returns to the page (e.g. after confirming in Telegram) + // Browsers throttle setTimeout in background tabs, so polling may have stalled. + useEffect(() => { + if (!deepLinkPolling || !deepLinkToken) return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && mountedRef.current) { + // Clear any pending throttled timer and trigger an immediate poll + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + // Skip if another poll is already in-flight to prevent race conditions + if (pollInFlightRef.current) return; + const capturedCampaign = capturedCampaignRef.current; + const immediatePoll = async () => { + if (!mountedRef.current || pollInFlightRef.current) return; + pollInFlightRef.current = true; + try { + await loginWithDeepLink(deepLinkToken, capturedCampaign); + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } + if (mountedRef.current) { + setDeepLinkPolling(false); + navigate('/'); + } + } catch (err: unknown) { + if (!mountedRef.current) return; + if (isAxiosError(err)) { + if (err.response?.status === 202) { + pollTimeoutRef.current = setTimeout(immediatePoll, DEEPLINK_POLL_INTERVAL_MS); + return; + } + if (err.response?.status === 410) { + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } + setDeepLinkPolling(false); + setDeepLinkToken(null); + setDeepLinkError(t('auth.deepLinkExpired')); + return; + } + } + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } + setDeepLinkPolling(false); + setDeepLinkError(t('common.error')); + } finally { + pollInFlightRef.current = false; + } + }; + immediatePoll(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Sever any in-flight recursive immediatePoll chain from this effect cycle + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }; + }, [deepLinkPolling, deepLinkToken, loginWithDeepLink, navigate, t]); + if (!botUsername || botUsername === 'your_bot') { return (
@@ -328,79 +430,90 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto // Deep link fallback UI if (scriptFailed) { + const resolvedBotUsername = deepLinkBotUsername || botUsername; const deepLinkUrl = deepLinkToken - ? `https://t.me/${deepLinkBotUsername || botUsername}?start=webauth_${deepLinkToken}` + ? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}` : ''; + const startCommand = deepLinkToken ? `/start webauth_${deepLinkToken}` : ''; return ( -
-
- {/* Info message */} -

- {t('auth.telegramWidgetBlocked')} -

+
+ {/* Info message */} +

+ {t('auth.telegramWidgetBlocked')} +

- {deepLinkToken && deepLinkUrl ? ( - <> - {/* Deep link button */} - - - - - {t('auth.openBotToLogin')} - - - {/* Polling status */} - {deepLinkPolling && ( -
- - {t('auth.waitingForConfirmation')} -
- )} - - ) : deepLinkError ? ( + {deepLinkToken && deepLinkUrl ? ( + <> + {/* QR Code */}
-

{deepLinkError}

+
+ +
+

{t('auth.scanQrToLogin')}

+
+ + {/* Open bot button */} + + + + + {t('auth.openBotToLogin')} + + + {/* Manual command */} +
+

{t('auth.orSendCommand')}

- ) : ( -
- - {t('common.loading')} -
- )} -
- {/* Bot link fallback */} -
-

{t('auth.orOpenInApp')}

- - - - - @{botUsername} - -
+ {/* Polling status */} + {deepLinkPolling && ( +
+ + {t('auth.waitingForConfirmation')} +
+ )} + + ) : deepLinkError ? ( +
+

{deepLinkError}

+ +
+ ) : ( +
+ + {t('common.loading')} +
+ )}
); } diff --git a/src/locales/en.json b/src/locales/en.json index 6e338c3..1728e45 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -180,6 +180,9 @@ "waitingForConfirmation": "Waiting for confirmation...", "deepLinkExpired": "Link expired. Please try again.", "tryAgain": "Try Again", + "scanQrToLogin": "Scan QR code with your phone camera", + "orSendCommand": "Or send the bot this command:", + "commandCopied": "Command copied", "welcomeBack": "Welcome back!", "loginTitle": "Login to Cabinet", "loginSubtitle": "Sign in with Telegram or use your email", @@ -1701,7 +1704,14 @@ "enableEmail": "Email", "sendingBoth": "2 broadcasts will be created", "bothCreated": "Broadcasts created", - "atLeastOneChannel": "Select at least one channel" + "atLeastOneChannel": "Select at least one channel", + "customButtons": "Custom buttons", + "addCustomButton": "Add button", + "customButtonLabelPlaceholder": "Button text", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "URL", + "customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Pinned Messages", diff --git a/src/locales/fa.json b/src/locales/fa.json index 797c3e9..467e54d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -172,6 +172,9 @@ "waitingForConfirmation": "در انتظار تایید...", "deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.", "tryAgain": "تلاش مجدد", + "scanQrToLogin": "کد QR را با دوربین گوشی اسکن کنید", + "orSendCommand": "یا این دستور را به ربات ارسال کنید:", + "commandCopied": "دستور کپی شد", "welcomeBack": "خوش آمدید!", "loginTitle": "ورود به کابین", "loginSubtitle": "با تلگرام یا ایمیل وارد شوید", @@ -1336,7 +1339,14 @@ "enableEmail": "Email", "sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد", "bothCreated": "پیام‌رسانی‌ها ایجاد شدند", - "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید" + "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید", + "customButtons": "دکمه‌های سفارشی", + "addCustomButton": "افزودن دکمه", + "customButtonLabelPlaceholder": "متن دکمه", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "لینک", + "customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Pinned Messages", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5137d97..c361682 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -183,6 +183,9 @@ "waitingForConfirmation": "Ожидание подтверждения...", "deepLinkExpired": "Ссылка истекла. Попробуйте снова.", "tryAgain": "Попробовать снова", + "scanQrToLogin": "Отсканируйте QR-код камерой телефона", + "orSendCommand": "Или отправьте боту команду:", + "commandCopied": "Команда скопирована", "welcomeBack": "Добро пожаловать!", "loginTitle": "Вход в личный кабинет", "loginSubtitle": "Войдите через Telegram или используйте email", @@ -1726,7 +1729,14 @@ "enableEmail": "Email", "sendingBoth": "Будет создано 2 рассылки", "bothCreated": "Рассылки созданы", - "atLeastOneChannel": "Выберите хотя бы один канал" + "atLeastOneChannel": "Выберите хотя бы один канал", + "customButtons": "Кастомные кнопки", + "addCustomButton": "Добавить кнопку", + "customButtonLabelPlaceholder": "Текст кнопки", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "Ссылка", + "customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Закреплённые сообщения", diff --git a/src/locales/zh.json b/src/locales/zh.json index 734a8bb..b6c525c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -172,6 +172,9 @@ "waitingForConfirmation": "等待确认...", "deepLinkExpired": "链接已过期,请重试。", "tryAgain": "重试", + "scanQrToLogin": "用手机相机扫描二维码", + "orSendCommand": "或向机器人发送命令:", + "commandCopied": "命令已复制", "welcomeBack": "欢迎回来!", "loginTitle": "登录个人中心", "loginSubtitle": "通过Telegram或邮箱登录", @@ -1410,7 +1413,14 @@ "enableEmail": "Email", "sendingBoth": "将创建2个群发", "bothCreated": "群发已创建", - "atLeastOneChannel": "请至少选择一个渠道" + "atLeastOneChannel": "请至少选择一个渠道", + "customButtons": "自定义按钮", + "addCustomButton": "添加按钮", + "customButtonLabelPlaceholder": "按钮文字", + "customButtonTypeCallback": "回调", + "customButtonTypeUrl": "链接", + "customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "置顶消息", diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 5e6073b..1709aab 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -7,6 +7,7 @@ import { BroadcastFilter, TariffFilter, CombinedBroadcastCreateRequest, + CustomBroadcastButton, } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; @@ -130,6 +131,11 @@ export default function AdminBroadcastCreate() { // Telegram-specific state const [messageText, setMessageText] = useState(''); const [selectedButtons, setSelectedButtons] = useState(['home']); + const [customButtons, setCustomButtons] = useState([]); + const [isAddingCustomButton, setIsAddingCustomButton] = useState(false); + const [newButtonLabel, setNewButtonLabel] = useState(''); + const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback'); + const [newButtonActionValue, setNewButtonActionValue] = useState(''); const [mediaFile, setMediaFile] = useState(null); const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo'); const [mediaPreview, setMediaPreview] = useState(null); @@ -275,28 +281,33 @@ export default function AdminBroadcastCreate() { const file = e.target.files?.[0]; if (!file) return; - setMediaFile(file); - + // Determine type locally to avoid stale state in async call + let detectedType: 'photo' | 'video' | 'document'; if (file.type.startsWith('image/')) { - setMediaType('photo'); + detectedType = 'photo'; + } else if (file.type.startsWith('video/')) { + detectedType = 'video'; + } else { + detectedType = 'document'; + } + + setMediaFile(file); + setMediaType(detectedType); + + if (detectedType === 'photo') { if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); const url = URL.createObjectURL(file); mediaPreviewRef.current = url; setMediaPreview(url); - } else if (file.type.startsWith('video/')) { - setMediaType('video'); - setMediaPreview(null); } else { - setMediaType('document'); setMediaPreview(null); } setIsUploading(true); try { - const result = await adminBroadcastsApi.uploadMedia(file, mediaType); + const result = await adminBroadcastsApi.uploadMedia(file, detectedType); setUploadedFileId(result.file_id); - } catch (err) { - console.error('Upload failed:', err); + } catch { setMediaFile(null); setMediaPreview(null); } finally { @@ -325,6 +336,39 @@ export default function AdminBroadcastCreate() { ); }; + // Custom button validation + const isNewButtonValid = useMemo(() => { + if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false; + if (newButtonActionType === 'url') { + return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim()); + } + if (newButtonActionType === 'callback') { + return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64; + } + return true; + }, [newButtonLabel, newButtonActionType, newButtonActionValue]); + + // Custom button handlers + const addCustomButton = () => { + if (!isNewButtonValid) return; + setCustomButtons((prev) => [ + ...prev, + { + label: newButtonLabel.trim(), + action_type: newButtonActionType, + action_value: newButtonActionValue.trim(), + }, + ]); + setNewButtonLabel(''); + setNewButtonActionValue(''); + setNewButtonActionType('callback'); + setIsAddingCustomButton(false); + }; + + const removeCustomButton = (index: number) => { + setCustomButtons((prev) => prev.filter((_, i) => i !== index)); + }; + // Validate form const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0; const isEmailValid = @@ -350,6 +394,7 @@ export default function AdminBroadcastCreate() { target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, + custom_buttons: customButtons.length > 0 ? customButtons : undefined, }; if (uploadedFileId) { data.media = { type: mediaType, file_id: uploadedFileId }; @@ -377,6 +422,7 @@ export default function AdminBroadcastCreate() { target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, + custom_buttons: customButtons.length > 0 ? customButtons : undefined, }; if (uploadedFileId) { telegramData.media = { type: mediaType, file_id: uploadedFileId }; @@ -655,6 +701,123 @@ export default function AdminBroadcastCreate() { ))}
+ + {/* Custom buttons */} +
+ + + {/* Existing custom buttons */} + {customButtons.length > 0 && ( +
+ {customButtons.map((btn, index) => ( +
+
+ + {btn.action_type === 'url' + ? t('admin.broadcasts.customButtonTypeUrl') + : t('admin.broadcasts.customButtonTypeCallback')} + + {btn.label} + {btn.action_value} +
+ +
+ ))} +
+ )} + + {/* Inline add form */} + {isAddingCustomButton ? ( +
{ + e.preventDefault(); + addCustomButton(); + }} + className="space-y-3 rounded-lg border border-dark-600 bg-dark-800/50 p-3" + > + setNewButtonLabel(e.target.value)} + placeholder={t('admin.broadcasts.customButtonLabelPlaceholder')} + maxLength={64} + className="input" + autoFocus + /> +
+ + +
+ setNewButtonActionValue(e.target.value)} + placeholder={ + newButtonActionType === 'url' + ? t('admin.broadcasts.customButtonUrlPlaceholder') + : t('admin.broadcasts.customButtonCallbackPlaceholder') + } + maxLength={newButtonActionType === 'callback' ? 64 : 256} + className="input" + /> +
+ + +
+
+ ) : ( + + )} +
)} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 50776b1..75174e1 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1387,8 +1387,10 @@ export default function AdminUserDetail() { {t('admin.users.detail.subscription.traffic')}
- {user.subscription.traffic_used_gb.toFixed(1)} /{' '} - {user.subscription.traffic_limit_gb} {t('common.units.gb')} + {panelInfo?.found + ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) + : user.subscription.traffic_used_gb.toFixed(1)}{' '} + / {user.subscription.traffic_limit_gb} {t('common.units.gb')}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index e32272e..adf0f8b 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -15,14 +15,14 @@ import { type BrandingInfo, type EmailAuthEnabled, } from '../api/branding'; -import { getAndClearReturnUrl } from '../utils/token'; +import { getAndClearReturnUrl, tokenStorage } from '../utils/token'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { closeMiniApp } from '@telegram-apps/sdk-react'; import LanguageSwitcher from '../components/LanguageSwitcher'; import TelegramLoginButton from '../components/TelegramLoginButton'; import OAuthProviderIcon from '../components/OAuthProviderIcon'; import { saveOAuthState } from '../utils/oauth'; -import { consumeReferralCode, getPendingReferralCode } from '../utils/referral'; +import { getPendingReferralCode } from '../utils/referral'; export default function Login() { const { t } = useTranslation(); @@ -146,16 +146,6 @@ export default function Login() { } }; - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; - - // If email auth is disabled but user came with ref param, redirect to bot - useEffect(() => { - if (referralCode && emailAuthConfig?.enabled === false && botUsername) { - consumeReferralCode(); - window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`; - } - }, [referralCode, emailAuthConfig, botUsername]); - const appName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN'; const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; @@ -215,11 +205,18 @@ export default function Login() { }, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]); const handleRetryTelegramAuth = () => { + // Clear ALL cached auth state to prevent stale token/initData loops + tokenStorage.clearTokens(); + sessionStorage.removeItem('tapps/launchParams'); + sessionStorage.removeItem('telegram_init_data'); + localStorage.removeItem('cabinet-auth'); + localStorage.removeItem('tg_user_id'); + try { - sessionStorage.removeItem('tapps/launchParams'); - sessionStorage.removeItem('telegram_init_data'); + // Close miniapp — Telegram will provide fresh initData on reopen closeMiniApp(); } catch { + // If closeMiniApp fails, force a clean page reload window.location.reload(); } }; diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 56700ae..e55104e 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -2124,7 +2124,12 @@ export default function Subscription() {
{device.device_model || device.platform}
-
{device.platform}
+
+ {device.platform} + + {device.hwid.slice(0, 8).toUpperCase()} + +
+

+ {t('subscription.extend')} +

+ +
+

+ {t('subscription.loadError', 'Не удалось загрузить варианты подписки')} +

+ +
+ + ); + } + return (
{/* Header with back link */} @@ -1936,6 +1993,30 @@ export default function SubscriptionPurchase() { )}
)} + + {/* No options available fallback */} + {purchaseOptions && + !optionsLoading && + !(isTariffsMode && tariffs.length > 0) && + !(classicOptions && classicOptions.periods.length > 0) && ( +
+

+ {t('subscription.noOptionsAvailable', 'Нет доступных вариантов подписки')} +

+ +
+ )} ); } diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 107a8ef..8ca1bcb 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -6,6 +6,7 @@ import { useAuthStore } from '../store/auth'; import { useShallow } from 'zustand/shallow'; import { brandingApi } from '../api/branding'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; +import { tokenStorage } from '../utils/token'; // Validate redirect URL to prevent open redirect attacks const getSafeRedirectUrl = (url: string | null): string => { @@ -117,6 +118,14 @@ export default function TelegramRedirect() { const newCount = retryCount + 1; setRetryCount(newCount); sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount)); + + // Clear all cached auth state to prevent stale token/initData loops + tokenStorage.clearTokens(); + sessionStorage.removeItem('tapps/launchParams'); + sessionStorage.removeItem('telegram_init_data'); + localStorage.removeItem('cabinet-auth'); + localStorage.removeItem('tg_user_id'); + setStatus('loading'); setErrorMessage(''); window.location.reload(); diff --git a/src/store/auth.ts b/src/store/auth.ts index 81b9602..8d0b841 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -45,7 +45,7 @@ interface AuthState { state: string, deviceId?: string | null, ) => Promise; - loginWithDeepLink: (token: string) => Promise; + loginWithDeepLink: (token: string, campaignSlug?: string | null) => Promise; registerWithEmail: ( email: string, password: string, @@ -322,8 +322,8 @@ export const useAuthStore = create()( await get().checkAdminStatus(); }, - loginWithDeepLink: async (token) => { - const response = await authApi.pollDeepLinkToken(token); + loginWithDeepLink: async (token, campaignSlug) => { + const response = await authApi.pollDeepLinkToken(token, campaignSlug); if (!response.access_token || !response.refresh_token) { throw new Error('Invalid auth response: missing tokens'); }