mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
35
.github/dependabot.yml
vendored
Normal file
35
.github/dependabot.yml
vendored
Normal 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
37
.github/workflows/codeql.yml
vendored
Normal 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"
|
||||
32
.github/workflows/docker.yml
vendored
32
.github/workflows/docker.yml
vendored
@@ -12,6 +12,9 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
@@ -22,6 +25,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -53,6 +57,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -66,3 +71,30 @@ jobs:
|
||||
VITE_TELEGRAM_BOT_USERNAME=
|
||||
VITE_APP_NAME=Cabinet
|
||||
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
35
.github/workflows/security-audit.yml
vendored
Normal 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
|
||||
@@ -24,8 +24,10 @@ ENV VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME
|
||||
ENV VITE_APP_NAME=$VITE_APP_NAME
|
||||
ENV VITE_APP_LOGO=$VITE_APP_LOGO
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# Build the application. Type-check намеренно пропущен: tsc --noEmit уже
|
||||
# гоняется CI на каждый PR (lint.yml), образ собирается из проверенного
|
||||
# коммита - повторная проверка стоила бы ~10s на каждую сборку.
|
||||
RUN npm run build:docker
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
@@ -40,3 +42,4 @@ EXPOSE 80
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# 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) | Адаптивный дизайн
|
||||
|
||||
## Требования
|
||||
|
||||
- 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)
|
||||
|
||||
## Архитектура
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:docker": "vite build",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -129,41 +129,56 @@
|
||||
// `file:` URI (DOM-XSS) is not scheme://-shaped, and http(s)/intent
|
||||
// are blocked (open redirect / intent abuse) — so only VPN app
|
||||
// schemes (happ://, vless://, ...) pass. Mirrors the validation in
|
||||
// src/pages/DeepLinkRedirect.tsx.
|
||||
function isSafeAppLink(raw) {
|
||||
var s = String(raw || '').trim().toLowerCase();
|
||||
var m = s.match(/^([a-z][a-z0-9+.\-]*):\/\//);
|
||||
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)) {
|
||||
// src/pages/DeepLinkRedirect.tsx. Both checks are INLINE
|
||||
// prefix-anchored RegExp tests so CodeQL recognizes them as taint
|
||||
// barriers (js/xss, js/client-side-unvalidated-url-redirection).
|
||||
function showNoUrl() {
|
||||
document.getElementById('title').textContent = t.noUrl;
|
||||
document.getElementById('subtitle').textContent = '';
|
||||
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;
|
||||
}
|
||||
|
||||
const manualBtn = document.getElementById('manualBtn');
|
||||
manualBtn.href = url;
|
||||
|
||||
// Attempt to launch the app WITHOUT a top-level navigation. A direct
|
||||
// `location.href = scheme` paints a full-page net::ERR_UNKNOWN_URL_SCHEME
|
||||
// inside Android in-app browsers (Telegram/Yandex/…) and silently fails on
|
||||
// iOS — hiding the manual button (Telegram bug #654272). A hidden iframe is
|
||||
// contained: the app opens if installed, otherwise the failure stays inside
|
||||
// the (invisible) frame and the button below stays usable.
|
||||
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) {
|
||||
// iPadOS 13+ reports itself as desktop Safari, so a touch-capable "Mac"
|
||||
// is treated as an iPad.
|
||||
function isIOS() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/iP(ad|hone|od)/.test(ua)) return true;
|
||||
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
||||
}
|
||||
|
||||
// Attempt to auto-launch the app. Two opposite fixes:
|
||||
// - iOS launches a custom scheme only from a top-level navigation; a hidden
|
||||
// iframe is a silent no-op there, so use location.href.
|
||||
// - Android in-app browsers (Telegram/Yandex/…) paint a full-page
|
||||
// net::ERR_UNKNOWN_URL_SCHEME on a top-level nav to an unresolved scheme
|
||||
// (Telegram bug #654272), so a contained hidden iframe is used instead.
|
||||
// Either way the manual "Open app" button below stays as the reliable path.
|
||||
if (isIOS()) {
|
||||
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
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface UserSubscriptionInfo {
|
||||
tariff_id: number | null;
|
||||
tariff_name: string | null;
|
||||
autopay_enabled: boolean;
|
||||
sbp_recurring_status: string | null;
|
||||
sbp_recurring_id: number | null;
|
||||
is_active: boolean;
|
||||
days_remaining: number;
|
||||
purchased_traffic_gb: number;
|
||||
@@ -508,6 +510,14 @@ export const adminUsersApi = {
|
||||
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
|
||||
updateStatus: async (
|
||||
userId: number,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import apiClient from './client';
|
||||
import { isEndpointMissingError } from '../utils/api-error';
|
||||
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
||||
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface TelegramWidgetConfig {
|
||||
request_access: boolean;
|
||||
oidc_enabled: boolean;
|
||||
oidc_client_id: string;
|
||||
/** The bot answered 404 on the config route — build older than v3.24.0. */
|
||||
endpoint_missing?: boolean;
|
||||
}
|
||||
|
||||
export interface OfflineConvGoal {
|
||||
@@ -321,7 +324,7 @@ export const brandingApi = {
|
||||
'/cabinet/branding/telegram-widget',
|
||||
);
|
||||
return response.data;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
return {
|
||||
bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '',
|
||||
size: 'large',
|
||||
@@ -330,6 +333,7 @@ export const brandingApi = {
|
||||
request_access: true,
|
||||
oidc_enabled: false,
|
||||
oidc_client_id: '',
|
||||
endpoint_missing: isEndpointMissingError(err),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PurchaseSelection,
|
||||
PurchasePreview,
|
||||
AppConfig,
|
||||
SbpRecurringInfo,
|
||||
} from '../types';
|
||||
|
||||
/** Helper: build query params with optional subscription_id */
|
||||
@@ -349,6 +350,50 @@ export const subscriptionApi = {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuthStore } from '../store/auth';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getPendingCampaignSlug } from '../utils/campaign';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { isEndpointMissingError } from '../utils/api-error';
|
||||
|
||||
interface TelegramLoginButtonProps {
|
||||
referralCode?: string;
|
||||
@@ -159,7 +160,6 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
isOIDC,
|
||||
widgetConfig?.oidc_client_id,
|
||||
widgetConfig?.request_access,
|
||||
t,
|
||||
scriptLoaded,
|
||||
handleScriptFailed,
|
||||
]);
|
||||
@@ -330,8 +330,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
},
|
||||
(expires_in || 300) * 1000,
|
||||
);
|
||||
} catch {
|
||||
setDeepLinkError(t('common.error'));
|
||||
} catch (err) {
|
||||
// 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]);
|
||||
|
||||
@@ -421,9 +422,11 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
}, [deepLinkPolling, deepLinkToken, loginWithDeepLink, navigate, t]);
|
||||
|
||||
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 (
|
||||
<div className="py-4 text-center text-sm text-dark-400">
|
||||
{t('auth.telegramNotConfigured')}
|
||||
{t(widgetConfig?.endpoint_missing ? 'auth.botOutdated' : 'auth.telegramNotConfigured')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||
import { useWebSocket, type WSMessage } from '../hooks/useWebSocket';
|
||||
import { useToast } from './Toast';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
@@ -296,6 +296,131 @@ export default function WebSocketNotifications() {
|
||||
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
|
||||
if (type === 'account.banned') {
|
||||
showToast({
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface SubscriptionTabProps {
|
||||
onAddTraffic: (gb: number) => Promise<void>;
|
||||
onRemoveTraffic: (purchaseId: number) => Promise<void>;
|
||||
onResetDevices: () => Promise<void>;
|
||||
onCancelSbpRecurring: () => Promise<void>;
|
||||
onDeleteDevice: (hwid: string) => Promise<void>;
|
||||
onRenameDevice: (hwid: string) => Promise<void>;
|
||||
onLoadDevices: () => Promise<void>;
|
||||
@@ -193,6 +194,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
onAddTraffic,
|
||||
onRemoveTraffic,
|
||||
onResetDevices,
|
||||
onCancelSbpRecurring,
|
||||
onDeleteDevice,
|
||||
onRenameDevice,
|
||||
onLoadDevices,
|
||||
@@ -226,6 +228,14 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
{sub.tariff_name || `#${sub.id}`}
|
||||
</span>
|
||||
<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>
|
||||
<ChevronRightIcon className="h-4 w-4 text-dark-500" />
|
||||
</div>
|
||||
@@ -380,6 +390,45 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
</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 */}
|
||||
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient, isChannelSubscriptionError } from '../../api/client';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||||
import { customChannelMessage } from '../../utils/channelBlockingMessage';
|
||||
import { TelegramIcon, ClockIcon, CheckIcon, RestartIcon } from '@/components/icons';
|
||||
import { Button } from '@/components/primitives';
|
||||
import BlockingShell from './BlockingShell';
|
||||
@@ -108,7 +109,9 @@ export default function ChannelSubscriptionScreen() {
|
||||
ariaLive="polite"
|
||||
icon={<TelegramIcon className="h-9 w-9" />}
|
||||
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')}
|
||||
actions={
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,8 @@ import { subscriptionApi } from '../../../api/subscription';
|
||||
import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers';
|
||||
import { useCurrency } from '../../../hooks/useCurrency';
|
||||
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
|
||||
import { usePlatform } from '../../../platform';
|
||||
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
|
||||
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
|
||||
import type { Tariff, TariffPeriod } from '../../../types';
|
||||
|
||||
@@ -31,6 +33,8 @@ export interface TariffPurchaseFormProps {
|
||||
tariff: Tariff;
|
||||
subscriptionId: number | undefined;
|
||||
balanceKopeks: number | undefined;
|
||||
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
|
||||
sbpPurchaseEnabled?: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
@@ -38,6 +42,7 @@ export function TariffPurchaseForm({
|
||||
tariff,
|
||||
subscriptionId,
|
||||
balanceKopeks,
|
||||
sbpPurchaseEnabled = false,
|
||||
onBack,
|
||||
}: TariffPurchaseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,6 +50,7 @@ export function TariffPurchaseForm({
|
||||
const queryClient = useQueryClient();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const { applyPromoDiscount } = usePromoDiscount();
|
||||
const { openLink, platform } = usePlatform();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
@@ -190,6 +239,8 @@ export function TariffPurchaseForm({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sbpPurchaseButton}
|
||||
|
||||
{purchaseMutation.isError &&
|
||||
!getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
@@ -613,6 +664,8 @@ export function TariffPurchaseForm({
|
||||
t('subscription.purchase')
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sbpPurchaseButton}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -116,6 +116,18 @@
|
||||
"insufficientTitle": "Insufficient funds",
|
||||
"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": {
|
||||
"bannedTitle": "Account blocked",
|
||||
"bannedMessage": "Your account has been blocked",
|
||||
@@ -189,6 +201,7 @@
|
||||
"telegramRetryFailed": "Authorization failed. Close the app and try again.",
|
||||
"telegramReopenHint": "If the problem persists, close and reopen the app",
|
||||
"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",
|
||||
"authenticating": "Authenticating...",
|
||||
"orOpenInApp": "Or open the bot in the app",
|
||||
@@ -250,6 +263,7 @@
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {{name}}!",
|
||||
"welcomeNoName": "Welcome!",
|
||||
"yourSubscription": "Your Subscription",
|
||||
"quickActions": "Quick Actions",
|
||||
"viewSubscription": "Manage Subscription",
|
||||
@@ -663,6 +677,30 @@
|
||||
},
|
||||
"allTariffsPurchased": "All tariffs already purchased",
|
||||
"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",
|
||||
"confirmDelete": "Delete subscription?",
|
||||
"dailyAutoCharge": "Daily auto-charge",
|
||||
@@ -839,7 +877,10 @@
|
||||
"pageTitle": "Saved Cards",
|
||||
"backToBalance": "Back",
|
||||
"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",
|
||||
"clickToOpenPayment": "Click the button below to open the payment page in a new tab",
|
||||
@@ -3539,7 +3580,13 @@
|
||||
"deviceLimitUpdated": "Device limit updated",
|
||||
"invalidDays": "Please enter a valid number of days (greater than 0)",
|
||||
"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": {
|
||||
"current": "Current balance",
|
||||
|
||||
@@ -110,6 +110,18 @@
|
||||
"insufficientTitle": "موجودی ناکافی",
|
||||
"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": {
|
||||
"bannedTitle": "حساب مسدود شد",
|
||||
"bannedMessage": "حساب شما مسدود شده است",
|
||||
@@ -181,6 +193,7 @@
|
||||
"telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.",
|
||||
"telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید",
|
||||
"telegramNotConfigured": "ربات تلگرام پیکربندی نشده",
|
||||
"botOutdated": "نسخهٔ ربات با کابینت سازگار نیست. ربات را به v3.33.0 یا جدیدتر بهروزرسانی کنید.",
|
||||
"telegramUnavailable": "ورود از طریق تلگرام موقتاً در دسترس نیست",
|
||||
"authenticating": "در حال تایید هویت...",
|
||||
"orOpenInApp": "یا ربات را در برنامه باز کنید",
|
||||
@@ -235,6 +248,7 @@
|
||||
"dashboard": {
|
||||
"title": "داشبورد",
|
||||
"welcome": "خوش آمدید، {{name}}!",
|
||||
"welcomeNoName": "خوش آمدید!",
|
||||
"yourSubscription": "اشتراک شما",
|
||||
"quickActions": "دسترسی سریع",
|
||||
"viewSubscription": "مدیریت اشتراک",
|
||||
@@ -562,6 +576,30 @@
|
||||
},
|
||||
"allTariffsPurchased": "همه تعرفهها قبلاً خریداری شدهاند",
|
||||
"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": "بازگشت به فهرست اشتراکها",
|
||||
"confirmDelete": "اشتراک حذف شود؟",
|
||||
"cta": {
|
||||
@@ -727,7 +765,10 @@
|
||||
"pageTitle": "کارتهای ذخیره شده",
|
||||
"backToBalance": "بازگشت",
|
||||
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره میشود.",
|
||||
"loadError": "خطا در بارگذاری کارتها. لطفاً بعداً دوباره امتحان کنید."
|
||||
"loadError": "خطا در بارگذاری کارتها. لطفاً بعداً دوباره امتحان کنید.",
|
||||
"sbpSection": "پرداخت خودکار SBP",
|
||||
"sbpBinding": "اتصال SBP",
|
||||
"sbpUnlink": "غیرفعال کردن پرداخت خودکار SBP"
|
||||
},
|
||||
"paymentSuccess": {
|
||||
"title": "پرداخت موفق",
|
||||
@@ -3010,7 +3051,13 @@
|
||||
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد",
|
||||
"invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)",
|
||||
"backToList": "بازگشت به فهرست اشتراکها",
|
||||
"createNew": "ایجاد جدید"
|
||||
"createNew": "ایجاد جدید",
|
||||
"sbpTitle": "پرداخت خودکار SBP",
|
||||
"sbpCancel": "غیرفعال کردن",
|
||||
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
|
||||
"sbpStatus_PENDING": "در انتظار",
|
||||
"sbpStatus_ACTIVE": "فعال",
|
||||
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
|
||||
},
|
||||
"balance": {
|
||||
"current": "موجودی فعلی",
|
||||
|
||||
@@ -119,6 +119,18 @@
|
||||
"insufficientTitle": "Недостаточно средств",
|
||||
"insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}"
|
||||
},
|
||||
"sbpRecurring": {
|
||||
"confirmedTitle": "Платёж по СБП подтверждён",
|
||||
"confirmedMessage": "Списано {{amount}} {{currency}}",
|
||||
"failedTitle": "Ошибка платежа по СБП",
|
||||
"failedMessage": "Не удалось списать платёж по СБП",
|
||||
"pastDueTitle": "Просрочен платёж по СБП",
|
||||
"pastDueMessage": "Платёж по СБП просрочен. Проверьте приложение банка.",
|
||||
"cancelledTitle": "Автоплатёж по СБП отключён",
|
||||
"cancelledMessage": "Автоплатёж по СБП был отключён",
|
||||
"activatedTitle": "Автоплатёж по СБП подключён",
|
||||
"activatedMessage": "Автоплатёж по СБП успешно подключён"
|
||||
},
|
||||
"account": {
|
||||
"bannedTitle": "Аккаунт заблокирован",
|
||||
"bannedMessage": "Ваш аккаунт был заблокирован",
|
||||
@@ -192,6 +204,7 @@
|
||||
"telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.",
|
||||
"telegramReopenHint": "Если проблема повторяется, закройте и откройте приложение заново",
|
||||
"telegramNotConfigured": "Telegram бот не настроен",
|
||||
"botOutdated": "Версия бота несовместима с кабинетом. Обновите бота до v3.33.0 или новее.",
|
||||
"telegramUnavailable": "Вход через Telegram временно недоступен",
|
||||
"authenticating": "Авторизация...",
|
||||
"orOpenInApp": "Или откройте бота в приложении",
|
||||
@@ -253,6 +266,7 @@
|
||||
"dashboard": {
|
||||
"title": "Личный кабинет",
|
||||
"welcome": "Добро пожаловать, {{name}}!",
|
||||
"welcomeNoName": "Добро пожаловать!",
|
||||
"yourSubscription": "Ваша подписка",
|
||||
"quickActions": "Быстрые действия",
|
||||
"viewSubscription": "Управление подпиской",
|
||||
@@ -682,6 +696,30 @@
|
||||
},
|
||||
"allTariffsPurchased": "Все тарифы уже куплены",
|
||||
"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": "К списку подписок",
|
||||
"confirmDelete": "Удалить подписку?",
|
||||
"dailyAutoCharge": "Ежедневное списание",
|
||||
@@ -858,7 +896,10 @@
|
||||
"pageTitle": "Сохранённые карты",
|
||||
"backToBalance": "Назад",
|
||||
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.",
|
||||
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже."
|
||||
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже.",
|
||||
"sbpSection": "Автоплатёж по СБП",
|
||||
"sbpBinding": "Привязка СБП",
|
||||
"sbpUnlink": "Отключить автоплатёж по СБП"
|
||||
},
|
||||
"paymentReady": "Ссылка на оплату готова",
|
||||
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
|
||||
@@ -3936,7 +3977,13 @@
|
||||
"deviceLimitUpdated": "Лимит устройств обновлён",
|
||||
"invalidDays": "Введите корректное количество дней (больше 0)",
|
||||
"backToList": "К списку подписок",
|
||||
"createNew": "Создать новую"
|
||||
"createNew": "Создать новую",
|
||||
"sbpTitle": "Автоплатёж по СБП",
|
||||
"sbpCancel": "Отключить",
|
||||
"sbpCancelled": "Автоплатёж по СБП отключён",
|
||||
"sbpStatus_PENDING": "Ожидает подтверждения",
|
||||
"sbpStatus_ACTIVE": "Активен",
|
||||
"sbpStatus_PAST_DUE": "Платёж не прошёл"
|
||||
},
|
||||
"balance": {
|
||||
"current": "Текущий баланс",
|
||||
|
||||
@@ -110,6 +110,18 @@
|
||||
"insufficientTitle": "余额不足",
|
||||
"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": {
|
||||
"bannedTitle": "账户已封禁",
|
||||
"bannedMessage": "您的账户已被封禁",
|
||||
@@ -181,6 +193,7 @@
|
||||
"telegramRetryFailed": "授权失败。请关闭应用后重新打开。",
|
||||
"telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用",
|
||||
"telegramNotConfigured": "Telegram机器人未配置",
|
||||
"botOutdated": "机器人版本与后台不兼容,请将机器人更新至 v3.33.0 或更高版本。",
|
||||
"telegramUnavailable": "Telegram登录暂时不可用",
|
||||
"authenticating": "正在验证...",
|
||||
"orOpenInApp": "或在应用中打开机器人",
|
||||
@@ -235,6 +248,7 @@
|
||||
"dashboard": {
|
||||
"title": "个人中心",
|
||||
"welcome": "欢迎,{{name}}!",
|
||||
"welcomeNoName": "欢迎!",
|
||||
"yourSubscription": "您的订阅",
|
||||
"quickActions": "快捷操作",
|
||||
"viewSubscription": "管理订阅",
|
||||
@@ -562,6 +576,30 @@
|
||||
},
|
||||
"allTariffsPurchased": "所有套餐已购买",
|
||||
"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": "返回订阅列表",
|
||||
"confirmDelete": "删除订阅?",
|
||||
"cta": {
|
||||
@@ -727,7 +765,10 @@
|
||||
"pageTitle": "已保存的银行卡",
|
||||
"backToBalance": "返回",
|
||||
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。",
|
||||
"loadError": "加载银行卡失败,请稍后重试。"
|
||||
"loadError": "加载银行卡失败,请稍后重试。",
|
||||
"sbpSection": "SBP 自动扣款",
|
||||
"sbpBinding": "SBP 绑定",
|
||||
"sbpUnlink": "关闭 SBP 自动扣款"
|
||||
},
|
||||
"paymentSuccess": {
|
||||
"title": "支付成功",
|
||||
@@ -3009,7 +3050,13 @@
|
||||
"deviceLimitUpdated": "设备限制已更新",
|
||||
"invalidDays": "请输入有效天数(大于0)",
|
||||
"backToList": "返回订阅列表",
|
||||
"createNew": "新建"
|
||||
"createNew": "新建",
|
||||
"sbpTitle": "SBP 自动扣款",
|
||||
"sbpCancel": "关闭",
|
||||
"sbpCancelled": "SBP 自动扣款已关闭",
|
||||
"sbpStatus_PENDING": "待确认",
|
||||
"sbpStatus_ACTIVE": "已启用",
|
||||
"sbpStatus_PAST_DUE": "扣款失败"
|
||||
},
|
||||
"balance": {
|
||||
"current": "当前余额",
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
tariffsApi,
|
||||
TariffDetail,
|
||||
TariffCreateRequest,
|
||||
TariffUpdateRequest,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
ExternalSquadInfo,
|
||||
type TariffDetail,
|
||||
type TariffCreateRequest,
|
||||
type TariffUpdateRequest,
|
||||
type PeriodPrice,
|
||||
type ServerInfo,
|
||||
type ExternalSquadInfo,
|
||||
} from '../api/tariffs';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
@@ -153,9 +153,12 @@ export default function AdminTariffCreate() {
|
||||
const handleSubmit = () => {
|
||||
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 = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
description: isEdit ? description : description || undefined,
|
||||
is_active: isActive,
|
||||
show_in_gift: showInGift,
|
||||
traffic_limit_gb: toNumber(trafficLimitGb, 100),
|
||||
@@ -167,7 +170,7 @@ export default function AdminTariffCreate() {
|
||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||
allowed_squads: selectedSquads,
|
||||
external_squad_uuid: selectedExternalSquad || null,
|
||||
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
||||
promo_group_ids: selectedPromoGroups,
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
||||
@@ -600,7 +603,7 @@ export default function AdminTariffCreate() {
|
||||
setEditingPeriodPrices((prev) => ({ ...prev, [period.days]: val }));
|
||||
if (val !== '') {
|
||||
const num = parseFloat(val);
|
||||
if (!isNaN(num)) {
|
||||
if (!Number.isNaN(num)) {
|
||||
updatePeriodPrice(period.days, Math.max(0, num));
|
||||
}
|
||||
}
|
||||
@@ -938,7 +941,7 @@ export default function AdminTariffCreate() {
|
||||
setEditingPackagePrices((prev) => ({ ...prev, [gb]: val }));
|
||||
if (val !== '') {
|
||||
const num = parseFloat(val);
|
||||
if (!isNaN(num)) {
|
||||
if (!Number.isNaN(num)) {
|
||||
setTrafficTopupPackages((prev) => ({
|
||||
...prev,
|
||||
[gb]: Math.max(0, num) * 100,
|
||||
|
||||
@@ -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 () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
@@ -713,7 +727,7 @@ export default function AdminUserDetail() {
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||
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) => {
|
||||
@@ -859,6 +873,7 @@ export default function AdminUserDetail() {
|
||||
<SubscriptionTab
|
||||
userSubscriptions={userSubscriptions}
|
||||
selectedSub={selectedSub}
|
||||
onCancelSbpRecurring={handleCancelSbpRecurring}
|
||||
activeSubscriptionId={activeSubscriptionId}
|
||||
onActiveSubscriptionChange={setActiveSubscriptionId}
|
||||
subscriptionDetailView={subscriptionDetailView}
|
||||
|
||||
@@ -251,12 +251,14 @@ export default function Dashboard() {
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
|
||||
const userName = displayName(user);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div data-onboarding="welcome">
|
||||
<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>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<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) */}
|
||||
{!isMultiTariff && (
|
||||
<>
|
||||
{subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="skeleton h-5 w-20" />
|
||||
<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>
|
||||
{!isMultiTariff &&
|
||||
(subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="skeleton h-5 w-20" />
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
) : subscription?.is_expired ||
|
||||
subscription?.status === 'disabled' ||
|
||||
subscription?.is_limited ? (
|
||||
<SubscriptionCardExpired
|
||||
subscription={subscription}
|
||||
balanceKopeks={balanceData?.balance_kopeks ?? 0}
|
||||
balanceRubles={balanceData?.balance_rubles ?? 0}
|
||||
/>
|
||||
) : subscription ? (
|
||||
<SubscriptionCardActive
|
||||
subscription={subscription}
|
||||
trafficData={trafficData}
|
||||
refreshTrafficMutation={refreshTrafficMutation}
|
||||
trafficRefreshCooldown={trafficRefreshCooldown}
|
||||
connectedDevices={devicesData?.total ?? 0}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
) : subscription?.is_expired ||
|
||||
subscription?.status === 'disabled' ||
|
||||
subscription?.is_limited ? (
|
||||
<SubscriptionCardExpired
|
||||
subscription={subscription}
|
||||
balanceKopeks={balanceData?.balance_kopeks ?? 0}
|
||||
balanceRubles={balanceData?.balance_rubles ?? 0}
|
||||
/>
|
||||
) : subscription ? (
|
||||
<SubscriptionCardActive
|
||||
subscription={subscription}
|
||||
trafficData={trafficData}
|
||||
refreshTrafficMutation={refreshTrafficMutation}
|
||||
trafficRefreshCooldown={trafficRefreshCooldown}
|
||||
connectedDevices={devicesData?.total ?? 0}
|
||||
/>
|
||||
) : null)}
|
||||
|
||||
{/* Нет подписок: показываем триал (если доступен) и ВСЕГДА одну явную
|
||||
кнопку покупки. Триал не обязателен, чтобы попасть в витрину — раньше
|
||||
|
||||
@@ -101,17 +101,21 @@ export default function Referral() {
|
||||
|
||||
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({
|
||||
queryKey: ['withdrawal-balance'],
|
||||
queryFn: withdrawalApi.getBalance,
|
||||
enabled: isPartner,
|
||||
enabled: withdrawalVisible,
|
||||
});
|
||||
|
||||
const { data: withdrawalHistory } = useQuery({
|
||||
queryKey: ['withdrawal-history'],
|
||||
queryFn: withdrawalApi.getHistory,
|
||||
enabled: isPartner,
|
||||
enabled: withdrawalVisible,
|
||||
});
|
||||
|
||||
// Withdrawal cancel mutation
|
||||
@@ -540,9 +544,9 @@ export default function Referral() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== Withdrawal Section (approved partners only) ==================== */}
|
||||
{/* ==================== Withdrawal Section ==================== */}
|
||||
|
||||
{terms?.partner_section_visible !== false && isPartner && (
|
||||
{withdrawalVisible && (
|
||||
<div id="withdrawal-section" className="space-y-6">
|
||||
{/* Withdrawal Balance Card */}
|
||||
{withdrawalBalance && (
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { uiLocale } from '@/utils/uiLocale';
|
||||
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 { useNavigate } from 'react-router';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { balanceApi } from '../api/balance';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||
import type { SbpRecurringInfo, SubscriptionListItem } from '../types';
|
||||
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
// key: remount the container when loading resolves — stagger orchestration
|
||||
// runs once on mount, so cards arriving from the API later would otherwise
|
||||
@@ -184,6 +266,62 @@ export default function SavedCards() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,13 +28,21 @@ import {
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
} from '../components/icons';
|
||||
import { useHaptic } from '../platform';
|
||||
import { useHaptic, usePlatform } from '../platform';
|
||||
import { resolveConnectionUrlForUi } from '../utils/connectionLink';
|
||||
import {
|
||||
getErrorMessage,
|
||||
getInsufficientBalanceError,
|
||||
getFlagEmoji,
|
||||
} 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 { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||||
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
||||
@@ -194,6 +202,8 @@ export default function Subscription() {
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
const haptic = useHaptic();
|
||||
const { openLink, platform } = usePlatform();
|
||||
const { showToast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showDeleteSheet, setShowDeleteSheet] = useState(false);
|
||||
const destructiveConfirm = useDestructiveConfirm();
|
||||
@@ -290,12 +300,90 @@ export default function Subscription() {
|
||||
|
||||
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({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -286,6 +286,12 @@ export default function SubscriptionPurchase() {
|
||||
tariff={selectedTariff}
|
||||
subscriptionId={subscriptionId}
|
||||
balanceKopeks={purchaseOptions?.balance_kopeks}
|
||||
sbpPurchaseEnabled={
|
||||
isTariffsMode &&
|
||||
purchaseOptions !== undefined &&
|
||||
'platega_recurrent_enabled' in purchaseOptions &&
|
||||
purchaseOptions.platega_recurrent_enabled === true
|
||||
}
|
||||
onBack={() => {
|
||||
setShowTariffPurchase(false);
|
||||
setSelectedTariff(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -9,13 +9,14 @@ import { infoApi } from '../api/info';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { logger } from '../utils/logger';
|
||||
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 { Button } from '@/components/primitives/Button';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { linkifyText } from '../utils/linkify';
|
||||
import { resolveSupportContact } from '../utils/supportContact';
|
||||
|
||||
const log = logger.createLogger('Support');
|
||||
|
||||
@@ -36,6 +37,19 @@ export default function Support() {
|
||||
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||
const queryClient = useQueryClient();
|
||||
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 [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
@@ -199,65 +213,29 @@ export default function Support() {
|
||||
if (supportConfig && !supportConfig.tickets_enabled) {
|
||||
log.debug('Tickets disabled, config:', supportConfig);
|
||||
|
||||
// Куда и чем открывать контакт — один резолв на весь блок. null → открывать
|
||||
// нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе.
|
||||
const contact = resolveSupportContact(supportConfig);
|
||||
|
||||
const getSupportMessage = () => {
|
||||
log.debug('Getting support message for type:', supportConfig.support_type);
|
||||
|
||||
if (supportConfig.support_type === 'profile') {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
const title = isAdmin ? t('support.ticketsDisabled') : t('support.title');
|
||||
|
||||
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
|
||||
return {
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
title,
|
||||
message: t('support.useExternalLink'),
|
||||
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';
|
||||
log.debug('Fallback: Opening profile:', supportUsername);
|
||||
return {
|
||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
||||
title,
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
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>
|
||||
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
|
||||
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
|
||||
<Button onClick={supportMessage.buttonAction} fullWidth>
|
||||
{supportMessage.buttonText}
|
||||
</Button>
|
||||
{contact && (
|
||||
<Button onClick={() => openSupportContact(supportConfig)} fullWidth>
|
||||
{supportMessage.buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -352,33 +332,30 @@ export default function Support() {
|
||||
{/* Contact support card for "both" mode — self-animated: mounts after the
|
||||
config query resolves, when the parent stagger orchestration has already
|
||||
finished and would leave it stuck at opacity 0 */}
|
||||
{supportConfig?.support_type === 'both' && supportConfig.support_username && (
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card className="flex items-center justify-between">
|
||||
<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" />
|
||||
{supportConfig?.support_type === 'both' &&
|
||||
supportConfig.support_username &&
|
||||
resolveSupportContact(supportConfig) && (
|
||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||
<Card className="flex items-center justify-between">
|
||||
<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 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>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
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>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
onClick={() => openSupportContact(supportConfig)}
|
||||
>
|
||||
{t('support.writeButton', 'Написать')}
|
||||
</Button>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Tickets List */}
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface WSMessage {
|
||||
new_expires_at?: string;
|
||||
tariff_name?: string;
|
||||
days_left?: number;
|
||||
// SBP recurring events
|
||||
status?: string;
|
||||
next_charge_at?: string | null;
|
||||
// Device purchase events
|
||||
devices_added?: number;
|
||||
new_device_limit?: number;
|
||||
|
||||
@@ -370,6 +370,9 @@ export interface TariffsPurchaseOptions {
|
||||
has_subscription?: boolean;
|
||||
// Multi-tariff: all available tariffs already purchased
|
||||
all_tariffs_purchased?: boolean;
|
||||
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
|
||||
// автооплатой СБП» рядом с покупкой с баланса
|
||||
platega_recurrent_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassicPurchaseOptions {
|
||||
@@ -530,6 +533,8 @@ export interface SupportConfig {
|
||||
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
||||
support_url?: string | null;
|
||||
support_username?: string | null;
|
||||
/** Резолвнутый контакт ведёт в Telegram, а не на внешний хелпдеск. */
|
||||
contact_is_telegram?: boolean;
|
||||
}
|
||||
|
||||
// Paginated response
|
||||
@@ -647,6 +652,15 @@ export interface SavedCardsResponse {
|
||||
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
|
||||
export interface TicketNotification {
|
||||
id: number;
|
||||
|
||||
59
src/utils/api-error.test.ts
Normal file
59
src/utils/api-error.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
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 {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = err.response?.data?.detail;
|
||||
|
||||
33
src/utils/channelBlockingMessage.test.ts
Normal file
33
src/utils/channelBlockingMessage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
18
src/utils/channelBlockingMessage.ts
Normal file
18
src/utils/channelBlockingMessage.ts
Normal 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;
|
||||
}
|
||||
51
src/utils/displayName.test.ts
Normal file
51
src/utils/displayName.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -3,21 +3,28 @@
|
||||
*
|
||||
* Why: single-letter first_name (e.g., "О") looked confusing alone ("Добро пожаловать, О!").
|
||||
* 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 {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
username?: string | null;
|
||||
telegram_id?: number | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export function displayName(user?: NameSource | null): string {
|
||||
if (!user) return '';
|
||||
const fullName = [user.first_name, user.last_name]
|
||||
.filter((part): part is string => Boolean(part && part.trim()))
|
||||
.filter((part): part is string => Boolean(part?.trim()))
|
||||
.join(' ');
|
||||
if (fullName) return fullName;
|
||||
if (user.username) return user.username;
|
||||
if (user.telegram_id) return `#${user.telegram_id}`;
|
||||
const emailLocalPart = user.email?.trim().split('@')[0];
|
||||
if (emailLocalPart) return emailLocalPart;
|
||||
return '';
|
||||
}
|
||||
|
||||
121
src/utils/openAppScheme.test.ts
Normal file
121
src/utils/openAppScheme.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,59 @@
|
||||
/**
|
||||
* Launch a custom-scheme app deep link (happ://, v2rayng://, vless://, …) without
|
||||
* crashing the page inside in-app browsers.
|
||||
* Launch a custom-scheme app deep link (happ://, incy://, v2rayng://, vless://, …)
|
||||
* 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
|
||||
* 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
|
||||
* nothing — which destroys the fallback UI (Telegram bug #654272). A hidden <iframe>
|
||||
* navigation is contained: if the app is installed the OS intercepts it; if not, the
|
||||
* failure stays inside the (invisible) frame and our page — with its manual "Open app"
|
||||
* link — survives so the user can tap it (a real user gesture is the reliable trigger).
|
||||
* Two failure modes have to be worked around, and they need opposite fixes:
|
||||
*
|
||||
* - Android in-app browsers (Telegram/Yandex/…): a top-level `location.href = scheme`
|
||||
* the WebView can't resolve paints a full-page `net::ERR_UNKNOWN_URL_SCHEME`, wiping
|
||||
* the fallback UI (Telegram bug #654272). A hidden <iframe> navigation is contained:
|
||||
* the OS intercepts it if the app is installed, otherwise the failure stays inside the
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// 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);
|
||||
if (isHttp) {
|
||||
window.location.href = url;
|
||||
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 {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
|
||||
109
src/utils/sbpRecurring.test.ts
Normal file
109
src/utils/sbpRecurring.test.ts
Normal 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
74
src/utils/sbpRecurring.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
97
src/utils/supportContact.test.ts
Normal file
97
src/utils/supportContact.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
63
src/utils/supportContact.ts
Normal file
63
src/utils/supportContact.ts
Normal 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}` };
|
||||
}
|
||||
Reference in New Issue
Block a user