diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index abd4863..f774774 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -456,7 +456,8 @@ export const adminUsersApi = { | 'traffic' | 'last_activity' | 'total_spent' - | 'purchase_count'; + | 'purchase_count' + | 'subscription_end_date'; } = {}, ): Promise => { const response = await apiClient.get('/cabinet/admin/users', { params }); @@ -518,6 +519,19 @@ export const adminUsersApi = { return response.data; }, + // Delete one of the user's subscriptions (multi-tariff: trials pile up) + deleteSubscription: async ( + userId: number, + subId: number, + force = false, + ): Promise<{ status: string }> => { + const response = await apiClient.delete( + `/cabinet/admin/users/${userId}/subscriptions/${subId}`, + { params: force ? { force: true } : undefined }, + ); + return response.data; + }, + // Update status updateStatus: async ( userId: number, diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index e459f1e..e162937 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -17,6 +17,8 @@ export interface PromoCode { balance_bonus_kopeks: number; balance_bonus_rubles: number; subscription_days: number; + /** Гигабайты к подписке — третья составляющая набора бонусов. */ + traffic_gb: number; max_uses: number; current_uses: number; uses_left: number; @@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest { type: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; @@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest { type?: PromoCodeType; balance_bonus_kopeks?: number; subscription_days?: number; + traffic_gb?: number; max_uses?: number; valid_from?: string; valid_until?: string | null; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 15dc748..9cc0c59 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const [oidcError, setOidcError] = useState(''); const [scriptLoaded, setScriptLoaded] = useState(false); const [scriptFailed, setScriptFailed] = useState(false); + // Lets the user opt into deep-link auth manually, without waiting for the + // Telegram widget script to fail. See #. + const [manualDeepLink, setManualDeepLink] = useState(false); + const showDeepLinkUI = scriptFailed || manualDeepLink; const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC); // Deep link auth state @@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget); useEffect(() => { - if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; + // showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер + // виджета размонтирован, а при возврате «Назад к виджету» сам по себе + // эффект не перезапустится — на legacy-пути scriptLoaded не меняется + // никогда, поэтому ни одна из остальных зависимостей не дрогнет, и + // пользователь получил бы пустое место вместо виджета. + if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return; const container = containerRef.current; while (container.firstChild) { @@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]); + }, [ + showDeepLinkUI, + isOIDC, + botUsername, + widgetConfig, + loginWithTelegramWidget, + navigate, + handleScriptFailed, + ]); // Deep link auth: request token and start polling with recursive setTimeout const startDeepLinkAuth = useCallback(async () => { @@ -336,9 +353,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }, [botUsername, loginWithDeepLink, navigate, t]); - // Auto-start deep link auth when script fails (with cancellation for Strict Mode) + // Auto-start deep link auth when script fails OR the user opts in manually + // (with cancellation for Strict Mode) useEffect(() => { - if (scriptFailed && !deepLinkToken && !deepLinkPolling) { + if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) { let cancelled = false; const start = async () => { if (!cancelled) await startDeepLinkAuth(); @@ -348,7 +366,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto cancelled = true; }; } - }, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); + }, [showDeepLinkUI, 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. @@ -431,8 +449,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto ); } - // Deep link fallback UI - if (scriptFailed) { + // Deep link UI — shown either as an automatic fallback (widget script + // failed to load) or because the user explicitly chose this method. + if (showDeepLinkUI) { const resolvedBotUsername = deepLinkBotUsername || botUsername; const deepLinkUrl = deepLinkToken ? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}` @@ -443,7 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
{/* Info message */}

- {t('auth.telegramWidgetBlocked')} + {t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}

{deepLinkToken && deepLinkUrl ? ( @@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto {t('common.loading')}
)} + + {/* Only offer a way back if the widget actually works — if the + script failed there is nothing to go back to. */} + {!scriptFailed && ( + + )} ); } @@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
)} -
-

{t('auth.orOpenInApp')}

+ {/* Referral deep link — only relevant for not-yet-registered users who + arrived via a referral link; the bot itself handles registering + them with the code attached. Hidden otherwise to avoid a third, + visually-identical "Telegram" entry point next to the two auth + methods below. */} + {referralCode && ( - - - - @{botUsername} + {t('auth.orOpenInApp')} @{botUsername} + )} + +
+
+ {t('common.or')} +
+ + {/* Manual opt-in: same deep-link flow used as the anti-block fallback, + offered here as an explicit equal alternative to the widget for + users who'd rather confirm in the bot than type a phone number. */} +
); } diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index a8b83c8..40e66de 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -131,6 +131,7 @@ export interface SubscriptionTabProps { onRemoveTraffic: (purchaseId: number) => Promise; onResetDevices: () => Promise; onCancelSbpRecurring: () => Promise; + onDeleteSubscription: () => Promise; onDeleteDevice: (hwid: string) => Promise; onRenameDevice: (hwid: string) => Promise; onLoadDevices: () => Promise; @@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onRemoveTraffic, onResetDevices, onCancelSbpRecurring, + onDeleteSubscription, onDeleteDevice, onRenameDevice, onLoadDevices, @@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
)} + {/* Delete this subscription — in multi-tariff mode spent trials + pile up in the card, and removing one used to be possible + only through the bulk-actions screen. */} + {hasPermission('users:subscription') && ( +
+
+
+
+ {t('admin.users.detail.subscription.deleteTitle')} +
+
+ {t('admin.users.detail.subscription.deleteHint')} +
+
+ +
+
+ )} + {/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
diff --git a/src/components/dashboard/ConnectDeviceTile.tsx b/src/components/dashboard/ConnectDeviceTile.tsx new file mode 100644 index 0000000..49929f3 --- /dev/null +++ b/src/components/dashboard/ConnectDeviceTile.tsx @@ -0,0 +1,143 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { useHaptic } from '../../platform'; +import { useTheme } from '../../hooks/useTheme'; +import { useTrafficZone } from '../../hooks/useTrafficZone'; +import { getGlassColors } from '../../utils/glassTheme'; +import { HoverBorderGradient } from '../ui/hover-border-gradient'; + +interface ConnectDeviceTileProps { + subscription: { + id: number; + device_limit: number; + subscription_url?: string | null; + }; + connectedDevices: number; + /** Процент израсходованного трафика — от него зависит акцентный цвет плитки. */ + usedPercent?: number; +} + +/** + * Плитка «Подключить устройство». + * + * Живёт и в карточке активной подписки, и на главной: пользователь, + * которому подписку выдал бонус рекламной кампании, попадает на главную + * с готовым доступом — и без этой плитки не понимает, что делать дальше. + */ +export default function ConnectDeviceTile({ + subscription, + connectedDevices, + usedPercent = 0, +}: ConnectDeviceTileProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const haptic = useHaptic(); + const { isDark } = useTheme(); + const g = getGlassColors(isDark); + const zone = useTrafficZone(usedPercent); + + const isAtDeviceLimit = + subscription.device_limit > 0 && connectedDevices >= subscription.device_limit; + + if (!subscription.subscription_url) return null; + + return ( + { + if (isAtDeviceLimit) { + haptic.notification('error'); + return; + } + navigate(`/connection?sub=${subscription.id}`); + }} + className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300 ${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} + data-onboarding="connect-devices" + style={{ fontFamily: 'inherit' }} + > + {/* Monitor icon */} +
+ +
+ + {/* Text */} +
+
+ {t('dashboard.connectDevice')} +
+
+ {subscription.device_limit === 0 + ? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices }) + : t('dashboard.devicesOfMax', { + used: connectedDevices, + max: subscription.device_limit, + })} +
+ {isAtDeviceLimit && ( +
+ {t('dashboard.deviceLimitReached')} +
+ )} +
+ + {/* Device indicator */} + {subscription.device_limit === 0 ? ( + + ) : subscription.device_limit <= 10 ? ( +