Merge pull request #510 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-07-25 01:28:52 +03:00
committed by GitHub
42 changed files with 1859 additions and 181 deletions

35
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
# Схлопываем шумные обновления в один PR, чтобы не плодить десятки веток.
dev-dependencies:
dependency-type: "development"
production-minor-patch:
dependency-type: "production"
update-types:
- "minor"
- "patch"
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "docker"

37
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: CodeQL
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
schedule:
# Еженедельный полный проход, чтобы ловить новые правила даже без коммитов.
- cron: "27 3 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:javascript-typescript"

View File

@@ -12,6 +12,9 @@ env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
jobs: jobs:
lint: lint:
uses: ./.github/workflows/lint.yml uses: ./.github/workflows/lint.yml
@@ -22,6 +25,7 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
security-events: write
steps: steps:
- name: Checkout code - name: Checkout code
@@ -53,6 +57,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
id: build
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
@@ -66,3 +71,30 @@ jobs:
VITE_TELEGRAM_BOT_USERNAME= VITE_TELEGRAM_BOT_USERNAME=
VITE_APP_NAME=Cabinet VITE_APP_NAME=Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V
# OCI-референсы требуют lowercase, а github.repository сохраняет регистр
# (BEDOLAGA-DEV/…) — Trivy падал бы на 'repository name must be lowercase'.
- name: Lowercase image name for Trivy
if: github.event_name != 'pull_request'
id: image
run: echo "name=${IMAGE_NAME,,}" >> "$GITHUB_OUTPUT"
# Сканируем запушенный образ на CVE. Пока non-blocking (exit-code: 0):
# находки уезжают в Security → Code scanning, но сборку не роняют.
# Для ужесточения — поставить severity-фильтр в exit-code и убрать PR-гейт.
- name: Scan image with Trivy
if: github.event_name != 'pull_request'
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: ${{ env.REGISTRY }}/${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: "0"
- name: Upload Trivy results to Security tab
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-results.sarif
category: trivy

35
.github/workflows/security-audit.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Security Audit
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
schedule:
- cron: "27 4 * * 1"
permissions:
contents: read
jobs:
npm-audit:
name: npm audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
# Пока non-blocking: сообщаем о HIGH/CRITICAL, но не валим пайплайн.
# Когда долги по зависимостям разгребём — убрать continue-on-error.
- name: Run npm audit (high+)
continue-on-error: true
run: npm audit --audit-level=high

View File

@@ -24,8 +24,10 @@ ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME
ENV VITE_APP_NAME=$VITE_APP_NAME ENV VITE_APP_NAME=$VITE_APP_NAME
ENV VITE_APP_LOGO=$VITE_APP_LOGO ENV VITE_APP_LOGO=$VITE_APP_LOGO
# Build the application # Build the application. Type-check намеренно пропущен: tsc --noEmit уже
RUN npm run build # гоняется CI на каждый PR (lint.yml), образ собирается из проверенного
# коммита - повторная проверка стоила бы ~10s на каждую сборку.
RUN npm run build:docker
# Stage 2: Serve with Nginx # Stage 2: Serve with Nginx
FROM nginx:alpine FROM nginx:alpine
@@ -40,3 +42,4 @@ EXPOSE 80
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1

View File

@@ -1,13 +1,13 @@
# Bedolaga Cabinet - Web Interface # Bedolaga Cabinet - Web Interface
Веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). Веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot v3.33.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot).
React + Vite + TypeScript | Авторизация через Telegram | Мультиязычность (EN/RU) | Адаптивный дизайн React + Vite + TypeScript | Авторизация через Telegram | Мультиязычность (EN/RU) | Адаптивный дизайн
## Требования ## Требования
- Docker и Docker Compose - Docker и Docker Compose
- Запущенный backend бота с включенным Cabinet API - Запущенный backend бота с включенным Cabinet API, версия **v3.33.0 или новее** — авторизация кабинета использует deep-link эндпоинты (`/cabinet/auth/deeplink/*`), которых нет в более старых версиях. На старом боте кабинет не сможет авторизовать пользователей (залоченные теги вроде `:3` не подходят — используйте `:latest` или конкретный тег ≥ 3.33.0)
- Обратный прокси (Caddy / Nginx / Traefik) - Обратный прокси (Caddy / Nginx / Traefik)
## Архитектура ## Архитектура

View File

@@ -8,6 +8,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:docker": "vite build",
"lint": "biome lint .", "lint": "biome lint .",
"lint:fix": "biome lint --write .", "lint:fix": "biome lint --write .",
"format": "biome format --write .", "format": "biome format --write .",

View File

@@ -129,41 +129,56 @@
// `file:` URI (DOM-XSS) is not scheme://-shaped, and http(s)/intent // `file:` URI (DOM-XSS) is not scheme://-shaped, and http(s)/intent
// are blocked (open redirect / intent abuse) — so only VPN app // are blocked (open redirect / intent abuse) — so only VPN app
// schemes (happ://, vless://, ...) pass. Mirrors the validation in // schemes (happ://, vless://, ...) pass. Mirrors the validation in
// src/pages/DeepLinkRedirect.tsx. // src/pages/DeepLinkRedirect.tsx. Both checks are INLINE
function isSafeAppLink(raw) { // prefix-anchored RegExp tests so CodeQL recognizes them as taint
var s = String(raw || '').trim().toLowerCase(); // barriers (js/xss, js/client-side-unvalidated-url-redirection).
var m = s.match(/^([a-z][a-z0-9+.\-]*):\/\//); function showNoUrl() {
if (!m) return false;
var blocked = ['http', 'https', 'file', 'blob', 'about', 'javascript', 'data', 'vbscript', 'intent', 'content'];
return blocked.indexOf(m[1]) === -1;
}
if (!url || !isSafeAppLink(url)) {
document.getElementById('title').textContent = t.noUrl; document.getElementById('title').textContent = t.noUrl;
document.getElementById('subtitle').textContent = ''; document.getElementById('subtitle').textContent = '';
document.querySelector('.spinner').style.display = 'none'; document.querySelector('.spinner').style.display = 'none';
}
if (!url || !/^[a-z][a-z0-9+.\-]*:\/\//i.test(url)) {
showNoUrl();
return;
}
if (/^(https?|file|blob|about|javascript|data|vbscript|intent|content|filesystem):/i.test(url)) {
showNoUrl();
return; return;
} }
const manualBtn = document.getElementById('manualBtn'); const manualBtn = document.getElementById('manualBtn');
manualBtn.href = url; manualBtn.href = url;
// Attempt to launch the app WITHOUT a top-level navigation. A direct // iPadOS 13+ reports itself as desktop Safari, so a touch-capable "Mac"
// `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME // is treated as an iPad.
// inside Android in-app browsers (Telegram/Yandex/…) and silently fails on function isIOS() {
// iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is var ua = navigator.userAgent || '';
// contained: the app opens if installed, otherwise the failure stays inside if (/iP(ad|hone|od)/.test(ua)) return true;
// the (invisible) frame and the button below stays usable. return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
try { }
var frame = document.createElement('iframe');
frame.style.display = 'none'; // Attempt to auto-launch the app. Two opposite fixes:
frame.src = url; // - iOS launches a custom scheme only from a top-level navigation; a hidden
document.body.appendChild(frame); // iframe is a silent no-op there, so use location.href.
setTimeout(function() { // - Android in-app browsers (Telegram/Yandex/…) paint a full-page
try { frame.parentNode.removeChild(frame); } catch (e) {} // net::ERR_UNKNOWN_URL_SCHEME on a top-level nav to an unresolved scheme
}, 2000); // (Telegram bug #654272), so a contained hidden iframe is used instead.
} catch (e) { // Either way the manual "Open app" button below stays as the reliable path.
if (isIOS()) {
window.location.href = url; window.location.href = url;
} else {
try {
var frame = document.createElement('iframe');
frame.style.display = 'none';
frame.src = url;
document.body.appendChild(frame);
setTimeout(function() {
try { frame.parentNode.removeChild(frame); } catch (e) {}
}, 2000);
} catch (e) {
window.location.href = url;
}
} }
// Programmatic opening is unreliable in in-app browsers, so surface the // Programmatic opening is unreliable in in-app browsers, so surface the

View File

@@ -21,6 +21,8 @@ export interface UserSubscriptionInfo {
tariff_id: number | null; tariff_id: number | null;
tariff_name: string | null; tariff_name: string | null;
autopay_enabled: boolean; autopay_enabled: boolean;
sbp_recurring_status: string | null;
sbp_recurring_id: number | null;
is_active: boolean; is_active: boolean;
days_remaining: number; days_remaining: number;
purchased_traffic_gb: number; purchased_traffic_gb: number;
@@ -508,6 +510,14 @@ export const adminUsersApi = {
return response.data; return response.data;
}, },
// Cancel a user's SBP (Platega) recurring auto-payment
cancelSbpRecurring: async (userId: number, subId: number): Promise<{ status: string }> => {
const response = await apiClient.post(
`/cabinet/admin/users/${userId}/subscriptions/${subId}/cancel-sbp-recurring`,
);
return response.data;
},
// Update status // Update status
updateStatus: async ( updateStatus: async (
userId: number, userId: number,

View File

@@ -1,4 +1,5 @@
import apiClient from './client'; import apiClient from './client';
import { isEndpointMissingError } from '../utils/api-error';
import type { AnimationConfig } from '@/components/ui/backgrounds/types'; import type { AnimationConfig } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
@@ -36,6 +37,8 @@ export interface TelegramWidgetConfig {
request_access: boolean; request_access: boolean;
oidc_enabled: boolean; oidc_enabled: boolean;
oidc_client_id: string; oidc_client_id: string;
/** The bot answered 404 on the config route — build older than v3.24.0. */
endpoint_missing?: boolean;
} }
export interface OfflineConvGoal { export interface OfflineConvGoal {
@@ -321,7 +324,7 @@ export const brandingApi = {
'/cabinet/branding/telegram-widget', '/cabinet/branding/telegram-widget',
); );
return response.data; return response.data;
} catch { } catch (err) {
return { return {
bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '', bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '',
size: 'large', size: 'large',
@@ -330,6 +333,7 @@ export const brandingApi = {
request_access: true, request_access: true,
oidc_enabled: false, oidc_enabled: false,
oidc_client_id: '', oidc_client_id: '',
endpoint_missing: isEndpointMissingError(err),
}; };
} }
}, },

View File

@@ -12,6 +12,7 @@ import type {
PurchaseSelection, PurchaseSelection,
PurchasePreview, PurchasePreview,
AppConfig, AppConfig,
SbpRecurringInfo,
} from '../types'; } from '../types';
/** Helper: build query params with optional subscription_id */ /** Helper: build query params with optional subscription_id */
@@ -349,6 +350,50 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ── SBP recurring (Platega) ───────────────────────────────────────────
getSbpRecurring: async (subscriptionId?: number): Promise<SbpRecurringInfo> => {
const response = await apiClient.get<SbpRecurringInfo>(
'/cabinet/subscription/platega-recurrent',
withSubId(subscriptionId),
);
return response.data;
},
enableSbpRecurring: async (
subscriptionId?: number,
): Promise<{ status: string; redirect_url: string | null }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/enable',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
cancelSbpRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/cancel',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
/**
* Оформление подписки на тариф через СБП-автопродление: первое списание =
* подтверждение привязки в банке. Для нового тарифа бэкенд создаёт
* неактивную заготовку, которую активирует первый чардж.
*/
purchaseWithSbpRecurring: async (
tariffId: number,
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/purchase',
{},
{ params: { tariff_id: tariffId } },
);
return response.data;
},
// ── Trial ─────────────────────────────────────────────────────────── // ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -9,6 +9,7 @@ import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { getPendingCampaignSlug } from '../utils/campaign'; import { getPendingCampaignSlug } from '../utils/campaign';
import { copyToClipboard } from '../utils/clipboard'; import { copyToClipboard } from '../utils/clipboard';
import { isEndpointMissingError } from '../utils/api-error';
interface TelegramLoginButtonProps { interface TelegramLoginButtonProps {
referralCode?: string; referralCode?: string;
@@ -159,7 +160,6 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
isOIDC, isOIDC,
widgetConfig?.oidc_client_id, widgetConfig?.oidc_client_id,
widgetConfig?.request_access, widgetConfig?.request_access,
t,
scriptLoaded, scriptLoaded,
handleScriptFailed, handleScriptFailed,
]); ]);
@@ -330,8 +330,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
}, },
(expires_in || 300) * 1000, (expires_in || 300) * 1000,
); );
} catch { } catch (err) {
setDeepLinkError(t('common.error')); // 404 = the deep-link auth routes don't exist on this bot build (< v3.33.0)
setDeepLinkError(t(isEndpointMissingError(err) ? 'auth.botOutdated' : 'common.error'));
} }
}, [botUsername, loginWithDeepLink, navigate, t]); }, [botUsername, loginWithDeepLink, navigate, t]);
@@ -421,9 +422,11 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
}, [deepLinkPolling, deepLinkToken, loginWithDeepLink, navigate, t]); }, [deepLinkPolling, deepLinkToken, loginWithDeepLink, navigate, t]);
if (!botUsername || botUsername === 'your_bot') { if (!botUsername || botUsername === 'your_bot') {
// 404 on the config route = the bot build predates the cabinet endpoints,
// which reads as "not configured" but is actually a version mismatch (#345).
return ( return (
<div className="py-4 text-center text-sm text-dark-400"> <div className="py-4 text-center text-sm text-dark-400">
{t('auth.telegramNotConfigured')} {t(widgetConfig?.endpoint_missing ? 'auth.botOutdated' : 'auth.telegramNotConfigured')}
</div> </div>
); );
} }

View File

@@ -7,7 +7,7 @@ import { useCallback } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useWebSocket, type WSMessage } from '../hooks/useWebSocket';
import { useToast } from './Toast'; import { useToast } from './Toast';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
@@ -296,6 +296,131 @@ export default function WebSocketNotifications() {
return; return;
} }
// SBP recurring events
if (type === 'sbp_recurring.confirmed') {
const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100;
showToast({
type: 'success',
title: t('wsNotifications.sbpRecurring.confirmedTitle', 'SBP payment confirmed'),
message: t(
'wsNotifications.sbpRecurring.confirmedMessage',
'Payment of {{amount}} {{currency}} was charged successfully',
{
amount: formatAmount(amount),
currency: currencySymbol,
},
),
icon: <span className="text-lg">🔁</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
refreshUser();
return;
}
if (type === 'sbp_recurring.activated') {
showToast({
type: 'success',
title: t('wsNotifications.sbpRecurring.activatedTitle', 'SBP auto-payment connected'),
message: t(
'wsNotifications.sbpRecurring.activatedMessage',
'SBP auto-payment has been successfully connected',
),
icon: <span className="text-lg">🔁</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
refreshUser();
return;
}
if (type === 'sbp_recurring.failed') {
showToast({
type: 'error',
title: t('wsNotifications.sbpRecurring.failedTitle', 'SBP payment failed'),
message: t(
'wsNotifications.sbpRecurring.failedMessage',
'Failed to charge your SBP auto-payment',
),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
if (type === 'sbp_recurring.past_due') {
showToast({
type: 'warning',
title: t('wsNotifications.sbpRecurring.pastDueTitle', 'SBP payment overdue'),
message: t(
'wsNotifications.sbpRecurring.pastDueMessage',
'Your SBP auto-payment is overdue. Please check your banking app.',
),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
if (type === 'sbp_recurring.cancelled') {
showToast({
type: 'warning',
title: t('wsNotifications.sbpRecurring.cancelledTitle', 'SBP auto-payment disabled'),
message: t(
'wsNotifications.sbpRecurring.cancelledMessage',
'SBP auto-payment has been disabled',
),
icon: <span className="text-lg">🔕</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
// Account events // Account events
if (type === 'account.banned') { if (type === 'account.banned') {
showToast({ showToast({

View File

@@ -130,6 +130,7 @@ export interface SubscriptionTabProps {
onAddTraffic: (gb: number) => Promise<void>; onAddTraffic: (gb: number) => Promise<void>;
onRemoveTraffic: (purchaseId: number) => Promise<void>; onRemoveTraffic: (purchaseId: number) => Promise<void>;
onResetDevices: () => Promise<void>; onResetDevices: () => Promise<void>;
onCancelSbpRecurring: () => Promise<void>;
onDeleteDevice: (hwid: string) => Promise<void>; onDeleteDevice: (hwid: string) => Promise<void>;
onRenameDevice: (hwid: string) => Promise<void>; onRenameDevice: (hwid: string) => Promise<void>;
onLoadDevices: () => Promise<void>; onLoadDevices: () => Promise<void>;
@@ -193,6 +194,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
onAddTraffic, onAddTraffic,
onRemoveTraffic, onRemoveTraffic,
onResetDevices, onResetDevices,
onCancelSbpRecurring,
onDeleteDevice, onDeleteDevice,
onRenameDevice, onRenameDevice,
onLoadDevices, onLoadDevices,
@@ -226,6 +228,14 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
{sub.tariff_name || `#${sub.id}`} {sub.tariff_name || `#${sub.id}`}
</span> </span>
<StatusBadge status={sub.status} /> <StatusBadge status={sub.status} />
{sub.sbp_recurring_status && (
<span
title={t('admin.users.detail.subscription.sbpTitle')}
className="rounded-full bg-accent-500/15 px-2 py-0.5 text-[10px] font-medium text-accent-400"
>
SBP
</span>
)}
</div> </div>
<ChevronRightIcon className="h-4 w-4 text-dark-500" /> <ChevronRightIcon className="h-4 w-4 text-dark-500" />
</div> </div>
@@ -380,6 +390,45 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
</div> </div>
</div> </div>
{/* SBP (Platega) recurring auto-payment — status comes from the admin
detail response; cancel is idempotent on the backend. */}
{selectedSub.sbp_recurring_status && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.sbpTitle')}
</div>
<div className="mt-0.5 text-xs text-dark-400">
{t(
`admin.users.detail.subscription.sbpStatus_${selectedSub.sbp_recurring_status}`,
selectedSub.sbp_recurring_status,
)}
</div>
</div>
{hasPermission('users:subscription') && (
<button
// Per-subscription confirm key: an armed confirm must not
// survive switching to another subscription in the picker.
onClick={() =>
onInlineConfirm(`cancelSbpRecurring_${selectedSub.id}`, onCancelSbpRecurring)
}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === `cancelSbpRecurring_${selectedSub.id}`
? 'bg-warning-500 text-white'
: 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
}`}
>
{confirmingAction === `cancelSbpRecurring_${selectedSub.id}`
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.subscription.sbpCancel')}
</button>
)}
</div>
</div>
)}
{/* Traffic Packages */} {/* Traffic Packages */}
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && ( {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4"> <div className="rounded-xl bg-dark-800/50 p-4">

View File

@@ -4,6 +4,7 @@ import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { customChannelMessage } from '../../utils/channelBlockingMessage';
import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons'; import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons';
import { Button } from '@/components/primitives'; import { Button } from '@/components/primitives';
import BlockingShell from './BlockingShell'; import BlockingShell from './BlockingShell';
@@ -108,7 +109,9 @@ export default function ChannelSubscriptionScreen() {
ariaLive="polite" ariaLive="polite"
icon={<TelegramIcon className="h-9 w-9" />} icon={<TelegramIcon className="h-9 w-9" />}
title={t('blocking.channel.title')} title={t('blocking.channel.title')}
description={channelInfo?.message || t('blocking.channel.defaultMessage')} description={
customChannelMessage(channelInfo?.message) ?? t('blocking.channel.defaultMessage')
}
footer={t('blocking.channel.hint')} footer={t('blocking.channel.hint')}
actions={ actions={
<Button <Button

View File

@@ -6,6 +6,8 @@ import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers'; import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers';
import { useCurrency } from '../../../hooks/useCurrency'; import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { usePlatform } from '../../../platform';
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt'; import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff, TariffPeriod } from '../../../types'; import type { Tariff, TariffPeriod } from '../../../types';
@@ -31,6 +33,8 @@ export interface TariffPurchaseFormProps {
tariff: Tariff; tariff: Tariff;
subscriptionId: number | undefined; subscriptionId: number | undefined;
balanceKopeks: number | undefined; balanceKopeks: number | undefined;
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
sbpPurchaseEnabled?: boolean;
onBack: () => void; onBack: () => void;
} }
@@ -38,6 +42,7 @@ export function TariffPurchaseForm({
tariff, tariff,
subscriptionId, subscriptionId,
balanceKopeks, balanceKopeks,
sbpPurchaseEnabled = false,
onBack, onBack,
}: TariffPurchaseFormProps) { }: TariffPurchaseFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -45,6 +50,7 @@ export function TariffPurchaseForm({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const { applyPromoDiscount } = usePromoDiscount(); const { applyPromoDiscount } = usePromoDiscount();
const { openLink, platform } = usePlatform();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const formatPrice = (kopeks: number) => const formatPrice = (kopeks: number) =>
@@ -93,6 +99,49 @@ export function TariffPurchaseForm({
}, },
}); });
// СБП-оформление: первое списание = подтверждение привязки в банке; период
// на форме не участвует — списания идут по каденс-правилу тарифа.
const sbpPurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseWithSbpRecurring(tariff.id),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', data.subscription_id] });
navigate('/subscriptions', { replace: true });
},
});
const sbpPurchaseButton = sbpPurchaseEnabled && (
<>
<button
onClick={() => sbpPurchaseMutation.mutate()}
disabled={sbpPurchaseMutation.isPending || purchaseMutation.isPending}
className="mt-2 w-full rounded-xl border border-accent-500/40 bg-accent-500/10 py-3 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/20 disabled:opacity-50"
>
{sbpPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('common.loading')}
</span>
) : (
t('subscription.sbpRecurring.purchaseButton')
)}
</button>
<div className="mt-1.5 text-center text-[11px] text-dark-500">
{t('subscription.sbpRecurring.purchaseHint')}
</div>
{sbpPurchaseMutation.isError && (
<div className="mt-2 text-center text-sm text-error-400">
{getErrorMessage(sbpPurchaseMutation.error)}
</div>
)}
</>
);
// Smooth scroll the form into view when first mounted. // Smooth scroll the form into view when first mounted.
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
@@ -190,6 +239,8 @@ export function TariffPurchaseForm({
)} )}
</button> </button>
{sbpPurchaseButton}
{purchaseMutation.isError && {purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && ( !getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400"> <div className="mt-3 text-center text-sm text-error-400">
@@ -613,6 +664,8 @@ export function TariffPurchaseForm({
t('subscription.purchase') t('subscription.purchase')
)} )}
</button> </button>
{sbpPurchaseButton}
</> </>
); );
})()} })()}

View File

@@ -116,6 +116,18 @@
"insufficientTitle": "Insufficient funds", "insufficientTitle": "Insufficient funds",
"insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}" "insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "SBP payment confirmed",
"confirmedMessage": "Payment of {{amount}} {{currency}} was charged successfully",
"failedTitle": "SBP payment failed",
"failedMessage": "Failed to charge your SBP auto-payment",
"pastDueTitle": "SBP payment overdue",
"pastDueMessage": "Your SBP auto-payment is overdue. Please check your banking app.",
"cancelledTitle": "SBP auto-payment disabled",
"cancelledMessage": "SBP auto-payment has been disabled",
"activatedTitle": "SBP auto-payment connected",
"activatedMessage": "SBP auto-payment has been successfully connected"
},
"account": { "account": {
"bannedTitle": "Account blocked", "bannedTitle": "Account blocked",
"bannedMessage": "Your account has been blocked", "bannedMessage": "Your account has been blocked",
@@ -189,6 +201,7 @@
"telegramRetryFailed": "Authorization failed. Close the app and try again.", "telegramRetryFailed": "Authorization failed. Close the app and try again.",
"telegramReopenHint": "If the problem persists, close and reopen the app", "telegramReopenHint": "If the problem persists, close and reopen the app",
"telegramNotConfigured": "Telegram bot is not configured", "telegramNotConfigured": "Telegram bot is not configured",
"botOutdated": "The bot version is not compatible with the cabinet. Update the bot to v3.33.0 or newer.",
"telegramUnavailable": "Telegram login is temporarily unavailable", "telegramUnavailable": "Telegram login is temporarily unavailable",
"authenticating": "Authenticating...", "authenticating": "Authenticating...",
"orOpenInApp": "Or open the bot in the app", "orOpenInApp": "Or open the bot in the app",
@@ -250,6 +263,7 @@
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"welcome": "Welcome, {{name}}!", "welcome": "Welcome, {{name}}!",
"welcomeNoName": "Welcome!",
"yourSubscription": "Your Subscription", "yourSubscription": "Your Subscription",
"quickActions": "Quick Actions", "quickActions": "Quick Actions",
"viewSubscription": "Manage Subscription", "viewSubscription": "Manage Subscription",
@@ -663,6 +677,30 @@
}, },
"allTariffsPurchased": "All tariffs already purchased", "allTariffsPurchased": "All tariffs already purchased",
"autopay": "Auto-renew", "autopay": "Auto-renew",
"sbpRecurring": {
"title": "SBP Auto-payment",
"connect": "Connect",
"confirmInBank": "Confirm in your banking app",
"cancel": "Disable",
"confirmCancel": "Disable SBP auto-payment? Your subscription will no longer renew automatically via SBP.",
"cancelled": "SBP auto-payment disabled",
"enableError": "Failed to connect SBP auto-payment",
"cancelError": "Failed to cancel SBP auto-payment",
"statusPending": "Waiting for confirmation in your banking app",
"statusActive": "Active",
"statusPastDue": "Payment failed — please retry",
"nextCharge": "next charge {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "daily",
"week": "weekly",
"month": "monthly",
"year": "yearly"
},
"autopayHint": "Your subscription will be charged automatically via SBP, no card required",
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
},
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?", "confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge", "dailyAutoCharge": "Daily auto-charge",
@@ -839,7 +877,10 @@
"pageTitle": "Saved Cards", "pageTitle": "Saved Cards",
"backToBalance": "Back", "backToBalance": "Back",
"empty": "No saved cards. A card will be saved automatically on your next top-up.", "empty": "No saved cards. A card will be saved automatically on your next top-up.",
"loadError": "Failed to load saved cards. Please try again later." "loadError": "Failed to load saved cards. Please try again later.",
"sbpSection": "SBP Auto-payment",
"sbpBinding": "SBP binding",
"sbpUnlink": "Disable SBP auto-payment"
}, },
"paymentReady": "Payment link is ready", "paymentReady": "Payment link is ready",
"clickToOpenPayment": "Click the button below to open the payment page in a new tab", "clickToOpenPayment": "Click the button below to open the payment page in a new tab",
@@ -3539,7 +3580,13 @@
"deviceLimitUpdated": "Device limit updated", "deviceLimitUpdated": "Device limit updated",
"invalidDays": "Please enter a valid number of days (greater than 0)", "invalidDays": "Please enter a valid number of days (greater than 0)",
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"createNew": "Create new" "createNew": "Create new",
"sbpTitle": "SBP Auto-payment",
"sbpCancel": "Disable",
"sbpCancelled": "SBP auto-payment disabled",
"sbpStatus_PENDING": "Pending",
"sbpStatus_ACTIVE": "Active",
"sbpStatus_PAST_DUE": "Payment failed"
}, },
"balance": { "balance": {
"current": "Current balance", "current": "Current balance",

View File

@@ -110,6 +110,18 @@
"insufficientTitle": "موجودی ناکافی", "insufficientTitle": "موجودی ناکافی",
"insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}" "insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "پرداخت SBP تأیید شد",
"confirmedMessage": "مبلغ {{amount}} {{currency}} با موفقیت برداشت شد",
"failedTitle": "پرداخت SBP ناموفق بود",
"failedMessage": "برداشت پرداخت خودکار SBP ناموفق بود",
"pastDueTitle": "پرداخت SBP معوق است",
"pastDueMessage": "پرداخت خودکار SBP شما معوق است. لطفاً اپلیکیشن بانک را بررسی کنید.",
"cancelledTitle": "پرداخت خودکار SBP غیرفعال شد",
"cancelledMessage": "پرداخت خودکار SBP غیرفعال شده است",
"activatedTitle": "پرداخت خودکار SBP متصل شد",
"activatedMessage": "پرداخت خودکار SBP با موفقیت متصل شد"
},
"account": { "account": {
"bannedTitle": "حساب مسدود شد", "bannedTitle": "حساب مسدود شد",
"bannedMessage": "حساب شما مسدود شده است", "bannedMessage": "حساب شما مسدود شده است",
@@ -181,6 +193,7 @@
"telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.", "telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.",
"telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید", "telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید",
"telegramNotConfigured": "ربات تلگرام پیکربندی نشده", "telegramNotConfigured": "ربات تلگرام پیکربندی نشده",
"botOutdated": "نسخهٔ ربات با کابینت سازگار نیست. ربات را به v3.33.0 یا جدیدتر به‌روزرسانی کنید.",
"telegramUnavailable": "ورود از طریق تلگرام موقتاً در دسترس نیست", "telegramUnavailable": "ورود از طریق تلگرام موقتاً در دسترس نیست",
"authenticating": "در حال تایید هویت...", "authenticating": "در حال تایید هویت...",
"orOpenInApp": "یا ربات را در برنامه باز کنید", "orOpenInApp": "یا ربات را در برنامه باز کنید",
@@ -235,6 +248,7 @@
"dashboard": { "dashboard": {
"title": "داشبورد", "title": "داشبورد",
"welcome": "خوش آمدید، {{name}}!", "welcome": "خوش آمدید، {{name}}!",
"welcomeNoName": "خوش آمدید!",
"yourSubscription": "اشتراک شما", "yourSubscription": "اشتراک شما",
"quickActions": "دسترسی سریع", "quickActions": "دسترسی سریع",
"viewSubscription": "مدیریت اشتراک", "viewSubscription": "مدیریت اشتراک",
@@ -562,6 +576,30 @@
}, },
"allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند", "allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند",
"autopay": "تمدید خودکار", "autopay": "تمدید خودکار",
"sbpRecurring": {
"title": "پرداخت خودکار SBP",
"connect": "اتصال",
"confirmInBank": "در اپلیکیشن بانک تأیید کنید",
"cancel": "غیرفعال کردن",
"confirmCancel": "پرداخت خودکار SBP غیرفعال شود؟ اشتراک شما دیگر به‌صورت خودکار از طریق SBP تمدید نخواهد شد.",
"cancelled": "پرداخت خودکار SBP غیرفعال شد",
"enableError": "اتصال پرداخت خودکار SBP ناموفق بود",
"cancelError": "لغو پرداخت خودکار SBP ناموفق بود",
"statusPending": "در انتظار تأیید در اپلیکیشن بانک",
"statusActive": "فعال",
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
"nextCharge": "برداشت بعدی {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "روزانه",
"week": "هفتگی",
"month": "ماهانه",
"year": "سالانه"
},
"autopayHint": "اشتراک شما به‌صورت خودکار از طریق SBP بدون نیاز به کارت ذخیره‌شده شارژ می‌شود",
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند."
},
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟", "confirmDelete": "اشتراک حذف شود؟",
"cta": { "cta": {
@@ -727,7 +765,10 @@
"pageTitle": "کارت‌های ذخیره شده", "pageTitle": "کارت‌های ذخیره شده",
"backToBalance": "بازگشت", "backToBalance": "بازگشت",
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.", "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.",
"loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید." "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید.",
"sbpSection": "پرداخت خودکار SBP",
"sbpBinding": "اتصال SBP",
"sbpUnlink": "غیرفعال کردن پرداخت خودکار SBP"
}, },
"paymentSuccess": { "paymentSuccess": {
"title": "پرداخت موفق", "title": "پرداخت موفق",
@@ -3010,7 +3051,13 @@
"deviceLimitUpdated": "محدودیت دستگاه به‌روز شد", "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد",
"invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)", "invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)",
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"createNew": "ایجاد جدید" "createNew": "ایجاد جدید",
"sbpTitle": "پرداخت خودکار SBP",
"sbpCancel": "غیرفعال کردن",
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
"sbpStatus_PENDING": "در انتظار",
"sbpStatus_ACTIVE": "فعال",
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
}, },
"balance": { "balance": {
"current": "موجودی فعلی", "current": "موجودی فعلی",

View File

@@ -119,6 +119,18 @@
"insufficientTitle": "Недостаточно средств", "insufficientTitle": "Недостаточно средств",
"insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}" "insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "Платёж по СБП подтверждён",
"confirmedMessage": "Списано {{amount}} {{currency}}",
"failedTitle": "Ошибка платежа по СБП",
"failedMessage": "Не удалось списать платёж по СБП",
"pastDueTitle": "Просрочен платёж по СБП",
"pastDueMessage": "Платёж по СБП просрочен. Проверьте приложение банка.",
"cancelledTitle": "Автоплатёж по СБП отключён",
"cancelledMessage": "Автоплатёж по СБП был отключён",
"activatedTitle": "Автоплатёж по СБП подключён",
"activatedMessage": "Автоплатёж по СБП успешно подключён"
},
"account": { "account": {
"bannedTitle": "Аккаунт заблокирован", "bannedTitle": "Аккаунт заблокирован",
"bannedMessage": "Ваш аккаунт был заблокирован", "bannedMessage": "Ваш аккаунт был заблокирован",
@@ -192,6 +204,7 @@
"telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.", "telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.",
"telegramReopenHint": "Если проблема повторяется, закройте и откройте приложение заново", "telegramReopenHint": "Если проблема повторяется, закройте и откройте приложение заново",
"telegramNotConfigured": "Telegram бот не настроен", "telegramNotConfigured": "Telegram бот не настроен",
"botOutdated": "Версия бота несовместима с кабинетом. Обновите бота до v3.33.0 или новее.",
"telegramUnavailable": "Вход через Telegram временно недоступен", "telegramUnavailable": "Вход через Telegram временно недоступен",
"authenticating": "Авторизация...", "authenticating": "Авторизация...",
"orOpenInApp": "Или откройте бота в приложении", "orOpenInApp": "Или откройте бота в приложении",
@@ -253,6 +266,7 @@
"dashboard": { "dashboard": {
"title": "Личный кабинет", "title": "Личный кабинет",
"welcome": "Добро пожаловать, {{name}}!", "welcome": "Добро пожаловать, {{name}}!",
"welcomeNoName": "Добро пожаловать!",
"yourSubscription": "Ваша подписка", "yourSubscription": "Ваша подписка",
"quickActions": "Быстрые действия", "quickActions": "Быстрые действия",
"viewSubscription": "Управление подпиской", "viewSubscription": "Управление подпиской",
@@ -682,6 +696,30 @@
}, },
"allTariffsPurchased": "Все тарифы уже куплены", "allTariffsPurchased": "Все тарифы уже куплены",
"autopay": "Автопродление", "autopay": "Автопродление",
"sbpRecurring": {
"title": "Автоплатёж по СБП",
"connect": "Подключить",
"confirmInBank": "Подтвердите в приложении банка",
"cancel": "Отключить",
"confirmCancel": "Отключить автоплатёж по СБП? Подписка больше не будет продлеваться автоматически через СБП.",
"cancelled": "Автоплатёж по СБП отключён",
"enableError": "Не удалось подключить автоплатёж по СБП",
"cancelError": "Не удалось отменить автоплатёж по СБП",
"statusPending": "Ожидает подтверждения в приложении банка",
"statusActive": "Активен",
"statusPastDue": "Платёж не прошёл — попробуйте снова",
"nextCharge": "следующее списание {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "ежедневно",
"week": "еженедельно",
"month": "ежемесячно",
"year": "ежегодно"
},
"autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты",
"purchaseButton": "⚡ Оформить с автооплатой СБП",
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
},
"backToList": "К списку подписок", "backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?", "confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание", "dailyAutoCharge": "Ежедневное списание",
@@ -858,7 +896,10 @@
"pageTitle": "Сохранённые карты", "pageTitle": "Сохранённые карты",
"backToBalance": "Назад", "backToBalance": "Назад",
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.", "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.",
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже." "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже.",
"sbpSection": "Автоплатёж по СБП",
"sbpBinding": "Привязка СБП",
"sbpUnlink": "Отключить автоплатёж по СБП"
}, },
"paymentReady": "Ссылка на оплату готова", "paymentReady": "Ссылка на оплату готова",
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
@@ -3936,7 +3977,13 @@
"deviceLimitUpdated": "Лимит устройств обновлён", "deviceLimitUpdated": "Лимит устройств обновлён",
"invalidDays": "Введите корректное количество дней (больше 0)", "invalidDays": "Введите корректное количество дней (больше 0)",
"backToList": "К списку подписок", "backToList": "К списку подписок",
"createNew": "Создать новую" "createNew": "Создать новую",
"sbpTitle": "Автоплатёж по СБП",
"sbpCancel": "Отключить",
"sbpCancelled": "Автоплатёж по СБП отключён",
"sbpStatus_PENDING": "Ожидает подтверждения",
"sbpStatus_ACTIVE": "Активен",
"sbpStatus_PAST_DUE": "Платёж не прошёл"
}, },
"balance": { "balance": {
"current": "Текущий баланс", "current": "Текущий баланс",

View File

@@ -110,6 +110,18 @@
"insufficientTitle": "余额不足", "insufficientTitle": "余额不足",
"insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}" "insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "SBP 扣款已确认",
"confirmedMessage": "已成功扣款 {{amount}} {{currency}}",
"failedTitle": "SBP 扣款失败",
"failedMessage": "SBP 自动扣款失败",
"pastDueTitle": "SBP 扣款已逾期",
"pastDueMessage": "您的 SBP 自动扣款已逾期,请检查银行应用。",
"cancelledTitle": "SBP 自动扣款已关闭",
"cancelledMessage": "SBP 自动扣款已被关闭",
"activatedTitle": "SBP 自动扣款已开通",
"activatedMessage": "SBP 自动扣款已成功开通"
},
"account": { "account": {
"bannedTitle": "账户已封禁", "bannedTitle": "账户已封禁",
"bannedMessage": "您的账户已被封禁", "bannedMessage": "您的账户已被封禁",
@@ -181,6 +193,7 @@
"telegramRetryFailed": "授权失败。请关闭应用后重新打开。", "telegramRetryFailed": "授权失败。请关闭应用后重新打开。",
"telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用", "telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用",
"telegramNotConfigured": "Telegram机器人未配置", "telegramNotConfigured": "Telegram机器人未配置",
"botOutdated": "机器人版本与后台不兼容,请将机器人更新至 v3.33.0 或更高版本。",
"telegramUnavailable": "Telegram登录暂时不可用", "telegramUnavailable": "Telegram登录暂时不可用",
"authenticating": "正在验证...", "authenticating": "正在验证...",
"orOpenInApp": "或在应用中打开机器人", "orOpenInApp": "或在应用中打开机器人",
@@ -235,6 +248,7 @@
"dashboard": { "dashboard": {
"title": "个人中心", "title": "个人中心",
"welcome": "欢迎,{{name}}", "welcome": "欢迎,{{name}}",
"welcomeNoName": "欢迎!",
"yourSubscription": "您的订阅", "yourSubscription": "您的订阅",
"quickActions": "快捷操作", "quickActions": "快捷操作",
"viewSubscription": "管理订阅", "viewSubscription": "管理订阅",
@@ -562,6 +576,30 @@
}, },
"allTariffsPurchased": "所有套餐已购买", "allTariffsPurchased": "所有套餐已购买",
"autopay": "自动续订", "autopay": "自动续订",
"sbpRecurring": {
"title": "SBP 自动扣款",
"connect": "开通",
"confirmInBank": "请在银行应用中确认",
"cancel": "关闭",
"confirmCancel": "关闭 SBP 自动扣款?您的订阅将不再通过 SBP 自动续订。",
"cancelled": "SBP 自动扣款已关闭",
"enableError": "开通 SBP 自动扣款失败",
"cancelError": "取消 SBP 自动扣款失败",
"statusPending": "等待银行应用确认",
"statusActive": "已启用",
"statusPastDue": "扣款失败 — 请重试",
"nextCharge": "下次扣款 {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "每天",
"week": "每周",
"month": "每月",
"year": "每年"
},
"autopayHint": "您的订阅将通过 SBP 自动扣款,无需保存银行卡",
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
},
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"confirmDelete": "删除订阅?", "confirmDelete": "删除订阅?",
"cta": { "cta": {
@@ -727,7 +765,10 @@
"pageTitle": "已保存的银行卡", "pageTitle": "已保存的银行卡",
"backToBalance": "返回", "backToBalance": "返回",
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。", "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。",
"loadError": "加载银行卡失败,请稍后重试。" "loadError": "加载银行卡失败,请稍后重试。",
"sbpSection": "SBP 自动扣款",
"sbpBinding": "SBP 绑定",
"sbpUnlink": "关闭 SBP 自动扣款"
}, },
"paymentSuccess": { "paymentSuccess": {
"title": "支付成功", "title": "支付成功",
@@ -3009,7 +3050,13 @@
"deviceLimitUpdated": "设备限制已更新", "deviceLimitUpdated": "设备限制已更新",
"invalidDays": "请输入有效天数大于0", "invalidDays": "请输入有效天数大于0",
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"createNew": "新建" "createNew": "新建",
"sbpTitle": "SBP 自动扣款",
"sbpCancel": "关闭",
"sbpCancelled": "SBP 自动扣款已关闭",
"sbpStatus_PENDING": "待确认",
"sbpStatus_ACTIVE": "已启用",
"sbpStatus_PAST_DUE": "扣款失败"
}, },
"balance": { "balance": {
"current": "当前余额", "current": "当前余额",

View File

@@ -4,12 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
tariffsApi, tariffsApi,
TariffDetail, type TariffDetail,
TariffCreateRequest, type TariffCreateRequest,
TariffUpdateRequest, type TariffUpdateRequest,
PeriodPrice, type PeriodPrice,
ServerInfo, type ServerInfo,
ExternalSquadInfo, type ExternalSquadInfo,
} from '../api/tariffs'; } from '../api/tariffs';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
@@ -153,9 +153,12 @@ export default function AdminTariffCreate() {
const handleSubmit = () => { const handleSubmit = () => {
const isDaily = tariffType === 'daily'; const isDaily = tariffType === 'daily';
// PATCH applies a field only when it is present in the payload, so empty
// values ('' / []) must still be sent when editing — omitting them makes
// clearing the description or unchecking all promo groups impossible.
const data: TariffCreateRequest | TariffUpdateRequest = { const data: TariffCreateRequest | TariffUpdateRequest = {
name, name,
description: description || undefined, description: isEdit ? description : description || undefined,
is_active: isActive, is_active: isActive,
show_in_gift: showInGift, show_in_gift: showInGift,
traffic_limit_gb: toNumber(trafficLimitGb, 100), traffic_limit_gb: toNumber(trafficLimitGb, 100),
@@ -167,7 +170,7 @@ export default function AdminTariffCreate() {
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0), period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
allowed_squads: selectedSquads, allowed_squads: selectedSquads,
external_squad_uuid: selectedExternalSquad || null, external_squad_uuid: selectedExternalSquad || null,
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined, promo_group_ids: selectedPromoGroups,
traffic_topup_enabled: trafficTopupEnabled, traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages, traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: toNumber(maxTopupTrafficGb), max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
@@ -600,7 +603,7 @@ export default function AdminTariffCreate() {
setEditingPeriodPrices((prev) => ({ ...prev, [period.days]: val })); setEditingPeriodPrices((prev) => ({ ...prev, [period.days]: val }));
if (val !== '') { if (val !== '') {
const num = parseFloat(val); const num = parseFloat(val);
if (!isNaN(num)) { if (!Number.isNaN(num)) {
updatePeriodPrice(period.days, Math.max(0, num)); updatePeriodPrice(period.days, Math.max(0, num));
} }
} }
@@ -938,7 +941,7 @@ export default function AdminTariffCreate() {
setEditingPackagePrices((prev) => ({ ...prev, [gb]: val })); setEditingPackagePrices((prev) => ({ ...prev, [gb]: val }));
if (val !== '') { if (val !== '') {
const num = parseFloat(val); const num = parseFloat(val);
if (!isNaN(num)) { if (!Number.isNaN(num)) {
setTrafficTopupPackages((prev) => ({ setTrafficTopupPackages((prev) => ({
...prev, ...prev,
[gb]: Math.max(0, num) * 100, [gb]: Math.max(0, num) * 100,

View File

@@ -648,6 +648,20 @@ export default function AdminUserDetail() {
} }
}; };
const handleCancelSbpRecurring = async () => {
if (!userId || !selectedSub?.sbp_recurring_id) return;
setActionLoading(true);
try {
await adminUsersApi.cancelSbpRecurring(userId, selectedSub.id);
notify.success(t('admin.users.detail.subscription.sbpCancelled'), t('common.success'));
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleDisableUser = async () => { const handleDisableUser = async () => {
if (!userId) return; if (!userId) return;
setActionLoading(true); setActionLoading(true);
@@ -713,7 +727,7 @@ export default function AdminUserDetail() {
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}; };
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
@@ -859,6 +873,7 @@ export default function AdminUserDetail() {
<SubscriptionTab <SubscriptionTab
userSubscriptions={userSubscriptions} userSubscriptions={userSubscriptions}
selectedSub={selectedSub} selectedSub={selectedSub}
onCancelSbpRecurring={handleCancelSbpRecurring}
activeSubscriptionId={activeSubscriptionId} activeSubscriptionId={activeSubscriptionId}
onActiveSubscriptionChange={setActiveSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId}
subscriptionDetailView={subscriptionDetailView} subscriptionDetailView={subscriptionDetailView}

View File

@@ -251,12 +251,14 @@ export default function Dashboard() {
setShowOnboarding(false); setShowOnboarding(false);
}; };
const userName = displayName(user);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 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: displayName(user) })} {userName ? t('dashboard.welcome', { name: userName }) : t('dashboard.welcomeNoName')}
</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>
@@ -328,40 +330,37 @@ export default function Dashboard() {
)} )}
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */} {/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
{!isMultiTariff && ( {!isMultiTariff &&
<> (subLoading ? (
{subLoading ? ( <div className="bento-card">
<div className="bento-card"> <div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex items-center justify-between"> <div className="skeleton h-5 w-20" />
<div className="skeleton h-5 w-20" /> <div className="skeleton h-6 w-16 rounded-full" />
<div className="skeleton h-6 w-16 rounded-full" />
</div>
<div className="skeleton mb-3 h-10 w-32" />
<div className="skeleton mb-3 h-4 w-40" />
<div className="skeleton h-3 w-full rounded-full" />
<div className="mt-5">
<div className="skeleton h-12 w-full rounded-xl" />
</div>
</div> </div>
) : subscription?.is_expired || <div className="skeleton mb-3 h-10 w-32" />
subscription?.status === 'disabled' || <div className="skeleton mb-3 h-4 w-40" />
subscription?.is_limited ? ( <div className="skeleton h-3 w-full rounded-full" />
<SubscriptionCardExpired <div className="mt-5">
subscription={subscription} <div className="skeleton h-12 w-full rounded-xl" />
balanceKopeks={balanceData?.balance_kopeks ?? 0} </div>
balanceRubles={balanceData?.balance_rubles ?? 0} </div>
/> ) : subscription?.is_expired ||
) : subscription ? ( subscription?.status === 'disabled' ||
<SubscriptionCardActive subscription?.is_limited ? (
subscription={subscription} <SubscriptionCardExpired
trafficData={trafficData} subscription={subscription}
refreshTrafficMutation={refreshTrafficMutation} balanceKopeks={balanceData?.balance_kopeks ?? 0}
trafficRefreshCooldown={trafficRefreshCooldown} balanceRubles={balanceData?.balance_rubles ?? 0}
connectedDevices={devicesData?.total ?? 0} />
/> ) : subscription ? (
) : null} <SubscriptionCardActive
</> subscription={subscription}
)} trafficData={trafficData}
refreshTrafficMutation={refreshTrafficMutation}
trafficRefreshCooldown={trafficRefreshCooldown}
connectedDevices={devicesData?.total ?? 0}
/>
) : null)}
{/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную {/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную
кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше

View File

@@ -101,17 +101,21 @@ export default function Referral() {
const isPartner = partnerStatus?.partner_status === 'approved'; const isPartner = partnerStatus?.partner_status === 'approved';
// Withdrawal queries (only when partner is approved) // Withdrawal is available to any referrer: the backend endpoints
// (/cabinet/referral/withdrawal/*) do not require partner_status, so the
// section is gated only by the admin visibility flag.
const withdrawalVisible = terms?.partner_section_visible !== false;
const { data: withdrawalBalance } = useQuery({ const { data: withdrawalBalance } = useQuery({
queryKey: ['withdrawal-balance'], queryKey: ['withdrawal-balance'],
queryFn: withdrawalApi.getBalance, queryFn: withdrawalApi.getBalance,
enabled: isPartner, enabled: withdrawalVisible,
}); });
const { data: withdrawalHistory } = useQuery({ const { data: withdrawalHistory } = useQuery({
queryKey: ['withdrawal-history'], queryKey: ['withdrawal-history'],
queryFn: withdrawalApi.getHistory, queryFn: withdrawalApi.getHistory,
enabled: isPartner, enabled: withdrawalVisible,
}); });
// Withdrawal cancel mutation // Withdrawal cancel mutation
@@ -540,9 +544,9 @@ export default function Referral() {
</div> </div>
)} )}
{/* ==================== Withdrawal Section (approved partners only) ==================== */} {/* ==================== Withdrawal Section ==================== */}
{terms?.partner_section_visible !== false && isPartner && ( {withdrawalVisible && (
<div id="withdrawal-section" className="space-y-6"> <div id="withdrawal-section" className="space-y-6">
{/* Withdrawal Balance Card */} {/* Withdrawal Balance Card */}
{withdrawalBalance && ( {withdrawalBalance && (

View File

@@ -1,13 +1,15 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { balanceApi } from '../api/balance'; import { balanceApi } from '../api/balance';
import { subscriptionApi } from '../api/subscription';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import type { SbpRecurringInfo, SubscriptionListItem } from '../types';
import { Card } from '@/components/data-display/Card'; import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -24,6 +26,25 @@ function formatCardDate(dateStr: string): string {
} }
} }
/** Human-readable locale key for an SBP binding status (mirrors Subscription.tsx). */
function sbpStatusLabelKey(status: string): string | null {
switch (status) {
case 'PENDING':
return 'subscription.sbpRecurring.statusPending';
case 'ACTIVE':
return 'subscription.sbpRecurring.statusActive';
case 'PAST_DUE':
return 'subscription.sbpRecurring.statusPastDue';
default:
return null;
}
}
interface SbpBinding {
sub: SubscriptionListItem;
info: SbpRecurringInfo;
}
export default function SavedCards() { export default function SavedCards() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -73,6 +94,67 @@ export default function SavedCards() {
} }
}; };
// ── SBP (Platega) recurring bindings ────────────────────────────────
// Same query-key convention as the Subscription page (['sbp-recurring', subId])
// so the caches are shared and don't refetch redundantly when navigating
// between the two pages.
const { data: subscriptionsData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: subscriptionApi.getSubscriptions,
});
const nonTrialSubs = (subscriptionsData?.subscriptions ?? []).filter((sub) => !sub.is_trial);
const sbpQueries = useQueries({
queries: nonTrialSubs.map((sub) => ({
queryKey: ['sbp-recurring', sub.id],
queryFn: () => subscriptionApi.getSbpRecurring(sub.id),
retry: false,
})),
});
// No section at all when nothing is bound: either the feature is off
// (every query 403s) or none of the subscriptions has an active binding.
const sbpBindings: SbpBinding[] = nonTrialSubs.reduce<SbpBinding[]>((acc, sub, index) => {
const info = sbpQueries[index]?.data;
if (info && info.status !== 'none') {
acc.push({ sub, info });
}
return acc;
}, []);
const [unlinkingSubId, setUnlinkingSubId] = useState<number | null>(null);
const confirmUnlinkSbp = useDestructiveConfirm();
const handleUnlinkSbp = async (subId: number) => {
if (unlinkingSubId !== null) return;
const confirmed = await confirmUnlinkSbp(
t('subscription.sbpRecurring.confirmCancel'),
t('subscription.sbpRecurring.cancel'),
);
if (!confirmed) return;
setUnlinkingSubId(subId);
try {
await subscriptionApi.cancelSbpRecurring(subId);
await queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subId] });
showToast({
type: 'success',
title: t('subscription.sbpRecurring.cancelled'),
message: '',
duration: 3000,
});
} catch (error) {
console.error('Failed to unlink SBP binding:', error);
showToast({
type: 'error',
title: t('common.error'),
message: '',
duration: 3000,
});
} finally {
setUnlinkingSubId(null);
}
};
return ( return (
// key: remount the container when loading resolves — stagger orchestration // key: remount the container when loading resolves — stagger orchestration
// runs once on mount, so cards arriving from the API later would otherwise // runs once on mount, so cards arriving from the API later would otherwise
@@ -184,6 +266,62 @@ export default function SavedCards() {
</Card> </Card>
</motion.div> </motion.div>
) : null} ) : null}
{/* SBP (Platega) recurring bindings — convenience mirror of the
per-subscription block on the Subscription page. Rendered only
when at least one non-trial subscription has an active binding;
hidden entirely when the feature is off or nothing is bound. */}
{sbpBindings.length > 0 && (
<motion.div variants={staggerItem}>
<Card>
<h2 className="mb-3 text-sm font-semibold text-dark-100">
{t('balance.savedCards.sbpSection')}
</h2>
<div className="space-y-3">
{sbpBindings.map(({ sub, info }) => {
const statusKey = sbpStatusLabelKey(info.status);
return (
<div
key={sub.id}
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">🔁</span>
<div>
<div className="font-medium text-dark-100">
{sub.tariff_name || `#${sub.id}`}
</div>
<div className="text-xs text-dark-500">
{t('balance.savedCards.sbpBinding')}
{statusKey ? ` · ${t(statusKey)}` : ''}
{info.next_charge_at
? ` · ${t('subscription.sbpRecurring.nextCharge', {
date: new Date(info.next_charge_at).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
})}`
: ''}
</div>
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleUnlinkSbp(sub.id)}
loading={unlinkingSubId === sub.id}
className="text-error-400 hover:text-error-300"
>
{t('balance.savedCards.sbpUnlink')}
</Button>
</div>
);
})}
</div>
</Card>
</motion.div>
)}
</motion.div> </motion.div>
); );
} }

View File

@@ -28,13 +28,21 @@ import {
DownloadIcon, DownloadIcon,
TrashIcon, TrashIcon,
} from '../components/icons'; } from '../components/icons';
import { useHaptic } from '../platform'; import { useHaptic, usePlatform } from '../platform';
import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { resolveConnectionUrlForUi } from '../utils/connectionLink';
import { import {
getErrorMessage, getErrorMessage,
getInsufficientBalanceError, getInsufficientBalanceError,
getFlagEmoji, getFlagEmoji,
} from '../utils/subscriptionHelpers'; } from '../utils/subscriptionHelpers';
import { openPaymentUrl } from '../utils/openPaymentUrl';
import { useToast } from '../components/Toast';
import {
isSbpFeatureDisabledError,
sbpIntervalLabelKey,
sbpUiState,
type SbpUiState,
} from '../utils/sbpRecurring';
import Twemoji from 'react-twemoji'; import Twemoji from 'react-twemoji';
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet'; import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet'; import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
@@ -194,6 +202,8 @@ export default function Subscription() {
const { isDark } = useTheme(); const { isDark } = useTheme();
const g = getGlassColors(isDark); const g = getGlassColors(isDark);
const haptic = useHaptic(); const haptic = useHaptic();
const { openLink, platform } = usePlatform();
const { showToast } = useToast();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showDeleteSheet, setShowDeleteSheet] = useState(false); const [showDeleteSheet, setShowDeleteSheet] = useState(false);
const destructiveConfirm = useDestructiveConfirm(); const destructiveConfirm = useDestructiveConfirm();
@@ -290,12 +300,90 @@ export default function Subscription() {
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
// SBP (Platega) recurring auto-payment status. Polls every 8s while a
// payment is PENDING (waiting for bank-app confirmation) so the UI flips
// to 'active'/'past_due' without a manual refresh; stops polling otherwise.
const sbpQuery = useQuery({
queryKey: ['sbp-recurring', subscriptionId],
queryFn: () => subscriptionApi.getSbpRecurring(subscriptionId),
enabled: !!subscription && !subscription.is_trial,
retry: false,
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
});
const sbpInfo = sbpQuery.data;
// 403 with a specific detail means the feature itself is disabled on the
// backend — distinct from "not resolved yet" or "other error", both of
// which must fail quiet (render nothing) rather than flash the 'off' state.
const sbpFeatureDisabled = isSbpFeatureDisabledError(sbpQuery.error);
const sbpUiStateValue: SbpUiState =
sbpInfo !== undefined || sbpFeatureDisabled
? sbpUiState(sbpInfo, sbpFeatureDisabled)
: 'hidden';
const enableSbpMutation = useMutation({
mutationFn: () => subscriptionApi.enableSbpRecurring(subscriptionId),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
// Backend flips autopay_enabled off when SBP auto-pay is enabled.
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
},
onError: (error: unknown) => {
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
?.detail;
showToast({
type: 'error',
title: typeof detail === 'string' ? detail : t('subscription.sbpRecurring.enableError'),
message: '',
duration: 3000,
});
},
});
const cancelSbpMutation = useMutation({
mutationFn: () => subscriptionApi.cancelSbpRecurring(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
showToast({
type: 'success',
title: t('subscription.sbpRecurring.cancelled'),
message: '',
duration: 3000,
});
},
onError: (error: unknown) => {
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
?.detail;
showToast({
type: 'error',
title: typeof detail === 'string' ? detail : t('subscription.sbpRecurring.cancelError'),
message: '',
duration: 3000,
});
},
});
const handleCancelSbp = async () => {
const confirmed = await destructiveConfirm(
t('subscription.sbpRecurring.confirmCancel'),
t('subscription.sbpRecurring.cancel'),
);
if (!confirmed) return;
cancelSbpMutation.mutate();
};
const autopayMutation = useMutation({ const autopayMutation = useMutation({
mutationFn: (enabled: boolean) => mutationFn: (enabled: boolean) =>
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
// Enabling balance-autopay cancels SBP auto-pay server-side — refresh so
// the SBP block doesn't keep showing a now-stale 'active'/'pending' state.
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
}, },
}); });
@@ -1076,6 +1164,122 @@ export default function Subscription() {
</button> </button>
</div> </div>
)} )}
{/* ─── SBP Recurring Auto-payment ───
Sibling of the autopay toggle above, guarded ONLY by
is_trial + uiState — daily-tariff subscriptions must see
this block too (backend supports a day-interval charge). */}
{!subscription.is_trial && sbpUiStateValue !== 'hidden' && (
<div
className="mt-3 rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
{/* Заголовок и статус слева, компактное действие справа —
зеркально соседнему тогглу «Автопродление». На мобиле
кнопка падает вниз на всю ширину (w-full sm:w-auto). */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-dark-50">
{t('subscription.sbpRecurring.title')}
</div>
{sbpUiStateValue === 'off' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.autopayHint')}
</div>
)}
{sbpUiStateValue === 'pending' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.statusPending')}
</div>
)}
{sbpUiStateValue === 'active' && sbpInfo && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.amountPerInterval', {
amount: formatAmount((sbpInfo.amount_kopeks ?? 0) / 100),
interval: t(sbpIntervalLabelKey(sbpInfo.interval)),
})}
</div>
{sbpInfo.next_charge_at && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.nextCharge', {
date: new Date(sbpInfo.next_charge_at).toLocaleDateString(
uiLocale(),
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
),
})}
</div>
)}
</>
)}
{sbpUiStateValue === 'past_due' && (
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
{t('subscription.sbpRecurring.statusPastDue')}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:items-end">
{sbpUiStateValue === 'off' && (
<button
onClick={() => enableSbpMutation.mutate()}
disabled={enableSbpMutation.isPending}
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity disabled:opacity-50 sm:w-auto"
>
{enableSbpMutation.isPending ? (
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
t('subscription.sbpRecurring.connect')
)}
</button>
)}
{sbpUiStateValue === 'pending' && (
<>
{sbpInfo?.redirect_url && (
<button
onClick={() => {
if (sbpInfo.redirect_url) {
openPaymentUrl(sbpInfo.redirect_url, platform, openLink);
}
}}
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity sm:w-auto"
>
{t('subscription.sbpRecurring.confirmInBank')}
</button>
)}
<button
onClick={handleCancelSbp}
disabled={cancelSbpMutation.isPending}
className="text-[11px] font-medium transition-colors disabled:opacity-50 sm:text-right"
style={{ color: 'rgb(var(--color-critical-500))' }}
>
{t('subscription.sbpRecurring.cancel')}
</button>
</>
)}
{(sbpUiStateValue === 'active' || sbpUiStateValue === 'past_due') && (
<button
onClick={handleCancelSbp}
disabled={cancelSbpMutation.isPending}
className="w-full whitespace-nowrap rounded-xl border border-error-500/30 bg-error-500/10 px-5 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50 sm:w-auto"
>
{t('subscription.sbpRecurring.cancel')}
</button>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
})() })()

View File

@@ -286,6 +286,12 @@ export default function SubscriptionPurchase() {
tariff={selectedTariff} tariff={selectedTariff}
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
balanceKopeks={purchaseOptions?.balance_kopeks} balanceKopeks={purchaseOptions?.balance_kopeks}
sbpPurchaseEnabled={
isTariffsMode &&
purchaseOptions !== undefined &&
'platega_recurrent_enabled' in purchaseOptions &&
purchaseOptions.platega_recurrent_enabled === true
}
onBack={() => { onBack={() => {
setShowTariffPurchase(false); setShowTariffPurchase(false);
setSelectedTariff(null); setSelectedTariff(null);

View File

@@ -1,5 +1,5 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -9,13 +9,14 @@ import { infoApi } from '../api/info';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import type { TicketDetail } from '../types'; import type { SupportConfig, TicketDetail } from '../types';
import { Card } from '@/components/data-display/Card'; import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { linkifyText } from '../utils/linkify'; import { linkifyText } from '../utils/linkify';
import { resolveSupportContact } from '../utils/supportContact';
const log = logger.createLogger('Support'); const log = logger.createLogger('Support');
@@ -36,6 +37,19 @@ export default function Support() {
const isAdmin = useAuthStore((state) => state.isAdmin); const isAdmin = useAuthStore((state) => state.isAdmin);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openTelegramLink, openLink } = usePlatform(); const { openTelegramLink, openLink } = usePlatform();
const openSupportContact = useCallback(
(config: SupportConfig) => {
const target = resolveSupportContact(config);
if (!target) return;
if (target.kind === 'external') {
openLink(target.url, { tryInstantView: false });
} else {
openTelegramLink(target.url);
}
},
[openLink, openTelegramLink],
);
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null); const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
@@ -199,65 +213,29 @@ export default function Support() {
if (supportConfig && !supportConfig.tickets_enabled) { if (supportConfig && !supportConfig.tickets_enabled) {
log.debug('Tickets disabled, config:', supportConfig); log.debug('Tickets disabled, config:', supportConfig);
// Куда и чем открывать контакт — один резолв на весь блок. null → открывать
// нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе.
const contact = resolveSupportContact(supportConfig);
const getSupportMessage = () => { const getSupportMessage = () => {
log.debug('Getting support message for type:', supportConfig.support_type); log.debug('Getting support message for type:', supportConfig.support_type);
if (supportConfig.support_type === 'profile') { const title = isAdmin ? t('support.ticketsDisabled') : t('support.title');
const supportUsername = supportConfig.support_username || '@support';
log.debug('Opening profile:', supportUsername);
return {
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Button clicked, opening:', supportUsername);
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername;
const webUrl = `https://t.me/${username}`;
log.debug('Web URL:', webUrl);
// Use platform's openTelegramLink
openTelegramLink(webUrl);
},
};
}
if (supportConfig.support_type === 'url' && supportConfig.support_url) { if (supportConfig.support_type === 'url' && supportConfig.support_url) {
return { return {
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), title,
message: t('support.useExternalLink'), message: t('support.useExternalLink'),
buttonText: t('support.openSupport'), buttonText: t('support.openSupport'),
buttonAction: () => {
openLink(supportConfig.support_url!, { tryInstantView: false });
},
}; };
} }
// Fallback: contact support (should not normally happen if config is correct) // profile и любой fallback — контакт в телеграме
const supportUsername = supportConfig.support_username || '@support'; const supportUsername = supportConfig.support_username || '@support';
log.debug('Fallback: Opening profile:', supportUsername);
return { return {
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), title,
message: t('support.contactSupport', { username: supportUsername }), message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Fallback button clicked, opening:', supportUsername);
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername;
const webUrl = `https://t.me/${username}`;
log.debug('Fallback opening URL:', webUrl);
// Use platform's openTelegramLink
openTelegramLink(webUrl);
},
}; };
}; };
@@ -271,9 +249,11 @@ export default function Support() {
</div> </div>
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2> <h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
<p className="mb-6 text-dark-400">{supportMessage.message}</p> <p className="mb-6 text-dark-400">{supportMessage.message}</p>
<Button onClick={supportMessage.buttonAction} fullWidth> {contact && (
{supportMessage.buttonText} <Button onClick={() => openSupportContact(supportConfig)} fullWidth>
</Button> {supportMessage.buttonText}
</Button>
)}
</Card> </Card>
</div> </div>
); );
@@ -352,33 +332,30 @@ export default function Support() {
{/* Contact support card for "both" mode — self-animated: mounts after the {/* Contact support card for "both" mode — self-animated: mounts after the
config query resolves, when the parent stagger orchestration has already config query resolves, when the parent stagger orchestration has already
finished and would leave it stuck at opacity 0 */} finished and would leave it stuck at opacity 0 */}
{supportConfig?.support_type === 'both' && supportConfig.support_username && ( {supportConfig?.support_type === 'both' &&
<motion.div variants={staggerItem} initial="initial" animate="animate"> supportConfig.support_username &&
<Card className="flex items-center justify-between"> resolveSupportContact(supportConfig) && (
<div className="flex items-center gap-3"> <motion.div variants={staggerItem} initial="initial" animate="animate">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800"> <Card className="flex items-center justify-between">
<ChatIcon className="h-5 w-5 text-dark-400" /> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800">
<ChatIcon className="h-5 w-5 text-dark-400" />
</div>
<div>
<div className="text-sm font-medium text-dark-100">{t('support.contactUs')}</div>
<div className="text-xs text-dark-400">{supportConfig.support_username}</div>
</div>
</div> </div>
<div> <Button
<div className="text-sm font-medium text-dark-100">{t('support.contactUs')}</div> variant="secondary"
<div className="text-xs text-dark-400">{supportConfig.support_username}</div> className="shrink-0 whitespace-nowrap"
</div> onClick={() => openSupportContact(supportConfig)}
</div> >
<Button {t('support.writeButton', 'Написать')}
variant="secondary" </Button>
className="shrink-0 whitespace-nowrap" </Card>
onClick={() => { </motion.div>
const username = supportConfig.support_username!.startsWith('@') )}
? supportConfig.support_username!.slice(1)
: supportConfig.support_username!;
openTelegramLink(`https://t.me/${username}`);
}}
>
{t('support.writeButton', 'Написать')}
</Button>
</Card>
</motion.div>
)}
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Tickets List */} {/* Tickets List */}

View File

@@ -21,6 +21,9 @@ export interface WSMessage {
new_expires_at?: string; new_expires_at?: string;
tariff_name?: string; tariff_name?: string;
days_left?: number; days_left?: number;
// SBP recurring events
status?: string;
next_charge_at?: string | null;
// Device purchase events // Device purchase events
devices_added?: number; devices_added?: number;
new_device_limit?: number; new_device_limit?: number;

View File

@@ -370,6 +370,9 @@ export interface TariffsPurchaseOptions {
has_subscription?: boolean; has_subscription?: boolean;
// Multi-tariff: all available tariffs already purchased // Multi-tariff: all available tariffs already purchased
all_tariffs_purchased?: boolean; all_tariffs_purchased?: boolean;
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
// автооплатой СБП» рядом с покупкой с баланса
platega_recurrent_enabled?: boolean;
} }
export interface ClassicPurchaseOptions { export interface ClassicPurchaseOptions {
@@ -530,6 +533,8 @@ export interface SupportConfig {
support_type: 'tickets' | 'profile' | 'url' | 'both'; support_type: 'tickets' | 'profile' | 'url' | 'both';
support_url?: string | null; support_url?: string | null;
support_username?: string | null; support_username?: string | null;
/** Резолвнутый контакт ведёт в Telegram, а не на внешний хелпдеск. */
contact_is_telegram?: boolean;
} }
// Paginated response // Paginated response
@@ -647,6 +652,15 @@ export interface SavedCardsResponse {
recurrent_enabled: boolean; recurrent_enabled: boolean;
} }
// Platega SBP recurring auto-payment status for a subscription
export interface SbpRecurringInfo {
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
interval?: number; // 1=day,2=week,3=month,4=year
amount_kopeks?: number;
next_charge_at?: string | null;
redirect_url?: string | null;
}
// Ticket notifications types // Ticket notifications types
export interface TicketNotification { export interface TicketNotification {
id: number; id: number;

View File

@@ -0,0 +1,59 @@
import { AxiosError, AxiosHeaders } from 'axios';
import { describe, expect, it } from 'vitest';
import { getApiErrorMessage, isEndpointMissingError } from './api-error';
function axiosErrorWithStatus(status: number, detail?: unknown): AxiosError {
const headers = new AxiosHeaders();
const config = { headers };
return new AxiosError(
'Request failed',
'ERR_BAD_REQUEST',
config,
{},
{
status,
statusText: '',
headers,
config,
data: detail === undefined ? {} : { detail },
},
);
}
describe('isEndpointMissingError', () => {
it('returns true for an axios 404', () => {
expect(isEndpointMissingError(axiosErrorWithStatus(404))).toBe(true);
});
it('returns false for other axios statuses', () => {
expect(isEndpointMissingError(axiosErrorWithStatus(403))).toBe(false);
expect(isEndpointMissingError(axiosErrorWithStatus(500))).toBe(false);
});
it('returns false for non-axios errors', () => {
expect(isEndpointMissingError(new Error('boom'))).toBe(false);
expect(isEndpointMissingError(undefined)).toBe(false);
expect(isEndpointMissingError(null)).toBe(false);
});
});
describe('getApiErrorMessage', () => {
it('returns string detail from an axios error', () => {
expect(getApiErrorMessage(axiosErrorWithStatus(400, 'Code must not be empty'), 'fb')).toBe(
'Code must not be empty',
);
});
it('joins pydantic validation errors', () => {
const err = axiosErrorWithStatus(422, [
{ loc: ['body', 'name'], msg: 'field required' },
{ loc: ['body', 'days'], msg: 'must be positive' },
]);
expect(getApiErrorMessage(err, 'fb')).toBe('name: field required; days: must be positive');
});
it('falls back for non-axios errors and missing detail', () => {
expect(getApiErrorMessage(new Error('boom'), 'fb')).toBe('fb');
expect(getApiErrorMessage(axiosErrorWithStatus(500), 'fb')).toBe('fb');
});
});

View File

@@ -1,5 +1,15 @@
import axios from 'axios'; import axios from 'axios';
/**
* True when the backend answered 404 on the route itself — for cabinet
* endpoints that means the bot build is too old to have them (e.g. the
* deep-link auth routes exist only since bot v3.33.0), as opposed to a
* missing entity inside a handler.
*/
export function isEndpointMissingError(err: unknown): boolean {
return axios.isAxiosError(err) && err.response?.status === 404;
}
export function getApiErrorMessage(err: unknown, fallback: string): string { export function getApiErrorMessage(err: unknown, fallback: string): string {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail; const detail = err.response?.data?.detail;

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { customChannelMessage } from './channelBlockingMessage';
describe('customChannelMessage', () => {
it('returns null for empty values', () => {
expect(customChannelMessage(undefined)).toBeNull();
expect(customChannelMessage(null)).toBeNull();
expect(customChannelMessage('')).toBeNull();
expect(customChannelMessage(' ')).toBeNull();
});
it('returns null for the hardcoded backend default message', () => {
expect(
customChannelMessage('Please subscribe to the required channels to continue'),
).toBeNull();
});
it('ignores surrounding whitespace when matching the backend default', () => {
expect(
customChannelMessage(' Please subscribe to the required channels to continue '),
).toBeNull();
});
it('passes through a custom message', () => {
expect(customChannelMessage('Подпишитесь на наш канал @example')).toBe(
'Подпишитесь на наш канал @example',
);
});
it('trims a custom message', () => {
expect(customChannelMessage(' custom ')).toBe('custom');
});
});

View File

@@ -0,0 +1,18 @@
/**
* The backend hardcodes this English message in the 403
* `channel_subscription_required` payload (app/cabinet/dependencies.py). It is
* not admin-configurable and not localized, so showing it verbatim leaks
* English into every locale. Treat it as "no custom message" and let the UI
* fall back to its own i18n string.
*/
const BACKEND_DEFAULT_MESSAGES = new Set(['Please subscribe to the required channels to continue']);
/**
* Returns the backend-provided channel-subscription message only when it is a
* real custom message; `null` for empty values and known backend defaults.
*/
export function customChannelMessage(message: string | null | undefined): string | null {
const trimmed = message?.trim();
if (!trimmed || BACKEND_DEFAULT_MESSAGES.has(trimmed)) return null;
return trimmed;
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { displayName } from './displayName';
describe('displayName', () => {
it('returns empty string for missing user', () => {
expect(displayName(undefined)).toBe('');
expect(displayName(null)).toBe('');
});
it('combines first_name and last_name', () => {
expect(displayName({ first_name: 'Иван', last_name: 'Петров' })).toBe('Иван Петров');
expect(displayName({ first_name: 'Иван', last_name: null })).toBe('Иван');
expect(displayName({ first_name: null, last_name: 'Петров' })).toBe('Петров');
});
it('falls back to username when there is no name', () => {
expect(displayName({ first_name: null, last_name: null, username: 'ivan' })).toBe('ivan');
});
it('falls back to telegram_id when there is no name and no username', () => {
expect(displayName({ first_name: null, username: null, telegram_id: 123456 })).toBe('#123456');
});
it('falls back to the email local part for email-only users', () => {
expect(
displayName({
first_name: null,
last_name: null,
username: null,
telegram_id: null,
email: 'vasya@gmail.com',
}),
).toBe('vasya');
});
it('prefers telegram_id over email', () => {
expect(displayName({ telegram_id: 123456, email: 'vasya@gmail.com' })).toBe('#123456');
});
it('returns empty string when every source is empty', () => {
expect(
displayName({
first_name: ' ',
last_name: null,
username: null,
telegram_id: null,
email: null,
}),
).toBe('');
});
});

View File

@@ -3,21 +3,28 @@
* *
* Why: single-letter first_name (e.g., "О") looked confusing alone ("Добро пожаловать, О!"). * Why: single-letter first_name (e.g., "О") looked confusing alone ("Добро пожаловать, О!").
* Combining with last_name makes truncated/short first names readable. * Combining with last_name makes truncated/short first names readable.
*
* Email/OAuth registration does not collect a name, so for such users every
* Telegram-derived field is null — fall back to the email local part
* ("vasya@gmail.com" → "vasya") to avoid an empty name in greetings.
*/ */
export interface NameSource { export interface NameSource {
first_name?: string | null; first_name?: string | null;
last_name?: string | null; last_name?: string | null;
username?: string | null; username?: string | null;
telegram_id?: number | null; telegram_id?: number | null;
email?: string | null;
} }
export function displayName(user?: NameSource | null): string { export function displayName(user?: NameSource | null): string {
if (!user) return ''; if (!user) return '';
const fullName = [user.first_name, user.last_name] const fullName = [user.first_name, user.last_name]
.filter((part): part is string => Boolean(part && part.trim())) .filter((part): part is string => Boolean(part?.trim()))
.join(' '); .join(' ');
if (fullName) return fullName; if (fullName) return fullName;
if (user.username) return user.username; if (user.username) return user.username;
if (user.telegram_id) return `#${user.telegram_id}`; if (user.telegram_id) return `#${user.telegram_id}`;
const emailLocalPart = user.email?.trim().split('@')[0];
if (emailLocalPart) return emailLocalPart;
return ''; return '';
} }

View File

@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openAppScheme } from './openAppScheme';
// openAppScheme touches window/document/navigator; the suite runs in a plain node
// environment, so stub just enough of the DOM to observe which launch path is taken.
interface FakeIframe {
style: { display: string };
src: string;
remove: () => void;
}
function setup(nav: { userAgent?: string; platform?: string; maxTouchPoints?: number }) {
const location = { href: '' };
const createdIframes: FakeIframe[] = [];
vi.stubGlobal('navigator', {
userAgent: nav.userAgent ?? '',
platform: nav.platform ?? '',
maxTouchPoints: nav.maxTouchPoints ?? 0,
});
vi.stubGlobal('window', {
location,
setTimeout: () => 0,
});
vi.stubGlobal('document', {
body: { appendChild: () => undefined },
createElement: () => {
const iframe: FakeIframe = { style: { display: '' }, src: '', remove: () => undefined };
createdIframes.push(iframe);
return iframe;
},
});
return { location, createdIframes };
}
describe('openAppScheme', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('navigates directly for http(s) links (no iframe)', () => {
const { location, createdIframes } = setup({ userAgent: 'Mozilla/5.0 (Android)' });
openAppScheme('https://example.com/sub');
expect(location.href).toBe('https://example.com/sub');
expect(createdIframes).toHaveLength(0);
});
it('opens a custom scheme via top-level navigation on iOS (iPhone), not via iframe', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1',
});
openAppScheme('incy://import/https://sp.example.com/abc');
// iOS ignores iframe scheme launches — must be a real top-level navigation.
expect(location.href).toBe('incy://import/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(0);
});
it('treats touch-capable iPadOS (reports as Mac) as iOS', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1',
platform: 'MacIntel',
maxTouchPoints: 5,
});
openAppScheme('happ://add/https://sp.example.com/abc');
expect(location.href).toBe('happ://add/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(0);
});
it('uses a contained iframe for custom schemes on Android (keeps page alive)', () => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Linux; Android 13) Chrome/120',
});
openAppScheme('incy://import/https://sp.example.com/abc');
// Android in-app browsers would paint a full-page error on a top-level nav.
expect(location.href).toBe('');
expect(createdIframes).toHaveLength(1);
expect(createdIframes[0].src).toBe('incy://import/https://sp.example.com/abc');
});
it('treats a non-touch Mac (desktop Safari) as non-iOS', () => {
const { createdIframes } = setup({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Safari/605.1',
platform: 'MacIntel',
maxTouchPoints: 0,
});
openAppScheme('incy://import/https://sp.example.com/abc');
expect(createdIframes).toHaveLength(1);
});
});
describe('openAppScheme guard (defense-in-depth)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
'javascript:alert(1)',
'javascript://%0aalert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript://x',
'file:///etc/passwd',
'intent://scan/#Intent;end',
'no-scheme-at-all',
])('never navigates for dangerous input (%s)', (url) => {
const { location, createdIframes } = setup({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1',
});
openAppScheme(url);
expect(location.href).toBe('');
expect(createdIframes).toHaveLength(0);
});
it('still opens valid app schemes after the guard', () => {
const { location } = setup({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1',
});
openAppScheme('happ://add/https://sp.example.com/abc');
expect(location.href).toBe('happ://add/https://sp.example.com/abc');
});
});

View File

@@ -1,24 +1,59 @@
/** /**
* Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without * Launch a custom-scheme app deep link (happ://, incy://, v2rayng://, vless://, …)
* crashing the page inside in-app browsers. * without crashing the page inside in-app browsers, and — crucially — in a way that
* actually opens the app on iOS.
* *
* Why not `window.location.href = scheme`: a programmatic top-level navigation to a * Two failure modes have to be worked around, and they need opposite fixes:
* scheme the WebView can't resolve renders a full-page error — on Android in-app *
* browsers (Telegram/Yandex/…) `net::ERR_UNKNOWN_URL_SCHEME`, on iOS it silently does * - Android in-app browsers (Telegram/Yandex/…): a top-level `location.href = scheme`
* nothing — which destroys the fallback UI (Telegram bug #654272). A hidden <iframe> * the WebView can't resolve paints a full-page `net::ERR_UNKNOWN_URL_SCHEME`, wiping
* navigation is contained: if the app is installed the OS intercepts it; if not, the * the fallback UI (Telegram bug #654272). A hidden <iframe> navigation is contained:
* failure stays inside the (invisible) frame and our page — with its manual "Open app" * the OS intercepts it if the app is installed, otherwise the failure stays inside the
* link — survives so the user can tap it (a real user gesture is the reliable trigger). * invisible frame and our manual "Open app" link survives.
*
* - iOS (Safari + WKWebView): the OS launches a custom scheme ONLY from a top-level
* navigation tied to a user gesture. A hidden-iframe `src` to a custom scheme is a
* silent no-op — the app never opens even when installed. So on iOS we must use
* `location.href`. openAppScheme is called synchronously from the button's click
* handler, so the gesture is still active here and the installed app launches.
* *
* http(s) links are normal navigations and are passed straight to location.href. * http(s) links are normal navigations and are passed straight to location.href.
*/ */
/**
* True on iOS/iPadOS. iPadOS 13+ reports itself as desktop Safari, so a touch-capable
* "Mac" is treated as an iPad.
*/
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
if (/iP(ad|hone|od)/.test(ua)) return true;
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
}
export function openAppScheme(url: string): void { export function openAppScheme(url: string): void {
// Defense-in-depth: callers (DeepLinkRedirect allowlist, Connection config)
// validate upstream, but the utility itself must not trust its input — a
// missed validation at a future call site would otherwise become client-side
// XSS via `location.href = 'javascript:…'`. Both guards are INLINE
// prefix-anchored RegExp tests so CodeQL recognizes them as taint barriers
// (js/xss, js/client-side-unvalidated-url-redirection).
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return;
if (/^(javascript|data|vbscript|file|blob|about|intent|content|filesystem):/i.test(url)) return;
const isHttp = /^https?:\/\//i.test(url); const isHttp = /^https?:\/\//i.test(url);
if (isHttp) { if (isHttp) {
window.location.href = url; window.location.href = url;
return; return;
} }
// iOS ignores custom-scheme launches from an iframe — it needs a gesture-bound
// top-level navigation, which this is (called synchronously from the click handler).
if (isIOS()) {
window.location.href = url;
return;
}
try { try {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.style.display = 'none'; iframe.style.display = 'none';

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import type { SbpRecurringInfo } from '../types';
import { isSbpFeatureDisabledError, sbpIntervalLabelKey, sbpUiState } from './sbpRecurring';
/**
* Утилиты для СБП-автопродления (Platega recurrent). Бэк отдаёт GET-статусы
* только 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED — это
* терминальные состояния, до которых GET не доживает: запись либо удаляется,
* либо переоткрывается новым PENDING). UI на неизвестный статус должен
* деградировать в 'off', а не падать.
*/
describe('sbpIntervalLabelKey', () => {
it.each([
[1, 'subscription.sbpRecurring.interval.day'],
[2, 'subscription.sbpRecurring.interval.week'],
[3, 'subscription.sbpRecurring.interval.month'],
[4, 'subscription.sbpRecurring.interval.year'],
[undefined, 'subscription.sbpRecurring.interval.month'],
[99, 'subscription.sbpRecurring.interval.month'],
])('interval=%s -> %s', (interval, expected) => {
expect(sbpIntervalLabelKey(interval as number | undefined)).toBe(expected);
});
});
describe('isSbpFeatureDisabledError', () => {
it('403 + detail ровно "Platega recurrent disabled" -> true', () => {
const error = {
response: { status: 403, data: { detail: 'Platega recurrent disabled' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(true);
});
it('неверный статус (404) -> false', () => {
const error = {
response: { status: 404, data: { detail: 'Platega recurrent disabled' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('неверный текст detail -> false', () => {
const error = {
response: { status: 403, data: { detail: 'Something else' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('detail — объект (другая форма 403), не должно падать -> false', () => {
const error = {
response: { status: 403, data: { detail: { code: 'blacklisted', message: 'x' } } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('null error -> false, без исключений', () => {
expect(isSbpFeatureDisabledError(null)).toBe(false);
});
it('произвольный не-axios объект -> false, без исключений', () => {
expect(isSbpFeatureDisabledError({ foo: 'bar' })).toBe(false);
});
it('undefined -> false', () => {
expect(isSbpFeatureDisabledError(undefined)).toBe(false);
});
it('строка вместо объекта ошибки -> false', () => {
expect(isSbpFeatureDisabledError('plain string error')).toBe(false);
});
it('response без data -> false, без исключений', () => {
expect(isSbpFeatureDisabledError({ response: { status: 403 } })).toBe(false);
});
});
describe('sbpUiState', () => {
const info = (overrides: Partial<SbpRecurringInfo>): SbpRecurringInfo => ({
status: 'none',
...overrides,
});
it('фича отключена -> "hidden", даже если данные есть', () => {
expect(sbpUiState(info({ status: 'ACTIVE' }), true)).toBe('hidden');
});
it('info === undefined -> "off"', () => {
expect(sbpUiState(undefined, false)).toBe('off');
});
it('status \'none\' -> "off"', () => {
expect(sbpUiState(info({ status: 'none' }), false)).toBe('off');
});
it('status \'PENDING\' -> "pending"', () => {
expect(sbpUiState(info({ status: 'PENDING' }), false)).toBe('pending');
});
it('status \'ACTIVE\' -> "active"', () => {
expect(sbpUiState(info({ status: 'ACTIVE' }), false)).toBe('active');
});
it('status \'PAST_DUE\' -> "past_due"', () => {
expect(sbpUiState(info({ status: 'PAST_DUE' }), false)).toBe('past_due');
});
it('неизвестный статус \'CANCELLED\' -> "off"', () => {
expect(sbpUiState(info({ status: 'CANCELLED' }), false)).toBe('off');
});
});

74
src/utils/sbpRecurring.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { SbpRecurringInfo } from '../types';
/**
* Локализационный ключ для интервала списания СБП-автоплатежа.
* Бэк присылает 1=день, 2=неделя, 3=месяц, 4=год; неизвестное/отсутствующее
* значение деградирует в "месяц" — самый частый и безопасный дефолт.
*/
export function sbpIntervalLabelKey(interval: number | undefined): string {
switch (interval) {
case 1:
return 'subscription.sbpRecurring.interval.day';
case 2:
return 'subscription.sbpRecurring.interval.week';
case 3:
return 'subscription.sbpRecurring.interval.month';
case 4:
return 'subscription.sbpRecurring.interval.year';
default:
return 'subscription.sbpRecurring.interval.month';
}
}
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
/**
* true, если ошибка — это ровно "фича СБП-автоплатежа выключена на бэке"
* (403 с detail==='Platega recurrent disabled'). На бэке этот же 403-статус
* используют и другие guard'ы с detail-объектом (см. isBlacklistedError и
* соседей в api/client.ts) — поэтому сравнение строгое, без падения на любой
* форме detail (строка/объект/отсутствует).
*/
export function isSbpFeatureDisabledError(error: unknown): boolean {
if (!isRecord(error)) return false;
const response = error.response;
if (!isRecord(response)) return false;
if (response.status !== 403) return false;
const data = response.data;
if (!isRecord(data)) return false;
return data.detail === 'Platega recurrent disabled';
}
export type SbpUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
/**
* Сводит статус СБП-автоплатежа к состоянию UI. Бэк отдаёт из GET только
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' — CANCELLED/FAILED никогда не
* возвращаются (терминальные состояния), но на всякий случай любой
* нераспознанный статус тоже деградирует в 'off', а не падает/подвисает.
*/
export function sbpUiState(
info: SbpRecurringInfo | undefined,
featureDisabled: boolean,
): SbpUiState {
if (featureDisabled) return 'hidden';
if (!info) return 'off';
switch (info.status) {
case 'PENDING':
return 'pending';
case 'ACTIVE':
return 'active';
case 'PAST_DUE':
return 'past_due';
default:
return 'off';
}
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import type { SupportConfig } from '../types';
import { resolveSupportContact } from './supportContact';
/**
* Кабинет склеивал `https://t.me/${support_username}` и ломал внешний хелпдеск:
* SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из
* `https://help.example.com` получалось `https://t.me/https://help.example.com`.
* Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего.
*
* resolveSupportContact дополнительно защищается от битого конфига: чужие схемы
* (`javascript:` и т.п.) и URL-образный legacy-username не уводят в опенер, а
* возвращают null — кнопку в таком случае не рендерим.
*/
const config = (overrides: Partial<SupportConfig>): SupportConfig => ({
tickets_enabled: true,
support_type: 'both',
...overrides,
});
describe('resolveSupportContact', () => {
it('внешний хелпдеск открывается как обычная ссылка, без t.me', () => {
const target = resolveSupportContact(
config({
support_url: 'https://help.example.com',
support_username: 'https://help.example.com',
contact_is_telegram: false,
}),
);
expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' });
expect(target?.url).not.toContain('t.me');
});
it('телеграм-контакт открывается через telegram-ссылку', () => {
const target = resolveSupportContact(
config({
support_url: 'https://t.me/help',
support_username: '@help',
contact_is_telegram: true,
}),
);
expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' });
});
it('tg:// deep link — валидная схема', () => {
const target = resolveSupportContact(
config({ support_url: 'tg://resolve?domain=help', contact_is_telegram: true }),
);
expect(target).toEqual({ kind: 'telegram', url: 'tg://resolve?domain=help' });
});
describe('чужие схемы в support_url отсекаются', () => {
it.each([
'javascript:alert(1)',
'data:text/html,x',
'not a url',
])('%s -> null', (support_url) => {
expect(resolveSupportContact(config({ support_url }))).toBeNull();
});
});
describe('старый бэк без support_url — прежнее поведение', () => {
it.each([
['@help', 'https://t.me/help'],
['help', 'https://t.me/help'],
])('%s -> %s', (username, expected) => {
const target = resolveSupportContact(config({ support_username: username }));
expect(target).toEqual({ kind: 'telegram', url: expected });
});
it.each([
'https://help.example.com',
'help.example.com',
't.me/help',
])('URL-образный legacy username (%s) не клеится в t.me — null', (support_username) => {
expect(resolveSupportContact(config({ support_username }))).toBeNull();
});
it('контакт вообще не задан — кнопки нет (null, без дефолта @support)', () => {
expect(resolveSupportContact(config({}))).toBeNull();
expect(resolveSupportContact(config({ support_username: ' ' }))).toBeNull();
});
});
it('support_url без contact_is_telegram трактуется как телеграм', () => {
// Комбинация недостижима с текущим бэком, но деградировать надо в старое
// поведение, а не в переход по внешней ссылке.
const target = resolveSupportContact(config({ support_url: 'https://t.me/help' }));
expect(target?.kind).toBe('telegram');
});
});

View File

@@ -0,0 +1,63 @@
import type { SupportConfig } from '../types';
/**
* Куда вести пользователя по кнопке «Написать в поддержку».
*
* Telegram-ссылку открывают через openTelegramLink, внешнюю — обычным переходом.
*/
export type SupportContactTarget = {
kind: 'telegram' | 'external';
url: string;
};
// Открываем только то, что реально ведёт в браузер/телеграм. support_url приходит
// из админского SUPPORT_USERNAME — чужую схему (`javascript:`, `data:` и т.п.)
// нельзя пускать в опенер как есть.
const ALLOWED_SCHEMES = ['http:', 'https:', 'tg:'];
function isAllowedScheme(url: string): boolean {
try {
return ALLOWED_SCHEMES.includes(new URL(url).protocol);
} catch {
return false;
}
}
/**
* Резолвит контакт поддержки из конфига.
*
* SUPPORT_USERNAME на бэке принимает и `@username`, и произвольный URL, поэтому
* бэк отдаёт уже разрезолвленные `support_url` + `contact_is_telegram`. Клиент
* раньше склеивал `https://t.me/${support_username}` — на внешнем хелпдеске это
* давало `https://t.me/https://help.example.com`, и ссылка не открывалась.
*
* Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение,
* иначе новый фронт сломался бы на неподнятом бэкенде.
*
* Возвращает `null`, когда открывать нечего (пустой/битый конфиг, чужая схема,
* URL-образный legacy username) — вызывающий в этом случае не рендерит кнопку,
* а не уводит пользователя по мусорной ссылке.
*/
export function resolveSupportContact(config: SupportConfig): SupportContactTarget | null {
const serverUrl = config.support_url?.trim();
if (serverUrl) {
if (!isAllowedScheme(serverUrl)) return null;
// contact_is_telegram отсутствует только у старого бэка, а он не шлёт и
// support_url — так что явная проверка на false, а не на truthy.
const kind = config.contact_is_telegram === false ? 'external' : 'telegram';
return { kind, url: serverUrl };
}
const raw = config.support_username?.trim();
if (!raw) return null;
const username = raw.startsWith('@') ? raw.slice(1) : raw;
// Legacy-путь: голый URL здесь дал бы `t.me/https://…`, поэтому клеим t.me
// только из настоящего юзернейма — всё URL-образное отдаём резолвить бэку.
if (!/^[A-Za-z0-9_]{3,}$/.test(username)) return null;
return { kind: 'telegram', url: `https://t.me/${username}` };
}