Merge pull request #528 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-07-29 23:59:22 +03:00
committed by GitHub
33 changed files with 1617 additions and 284 deletions

View File

@@ -149,6 +149,11 @@ export const initLogoPreload = () => {
} }
}; };
export interface BotStartVideoInfo {
has_video: boolean;
file_id?: string | null;
}
export const brandingApi = { export const brandingApi = {
// Get current branding (public, no auth required) // Get current branding (public, no auth required)
getBranding: async (): Promise<BrandingInfo> => { getBranding: async (): Promise<BrandingInfo> => {
@@ -176,6 +181,29 @@ export const brandingApi = {
return response.data; return response.data;
}, },
// ── Видео стартового меню бота ────────────────────────────────────────
// Хранится как Telegram file_id: файл на нашей стороне не сохраняется.
getBotStartVideo: async (): Promise<BotStartVideoInfo> => {
const response = await apiClient.get<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
return response.data;
},
uploadBotStartVideo: async (file: File): Promise<BotStartVideoInfo> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<BotStartVideoInfo>(
'/cabinet/branding/bot-start-video',
formData,
);
return response.data;
},
deleteBotStartVideo: async (): Promise<BotStartVideoInfo> => {
const response = await apiClient.delete<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
return response.data;
},
// Delete custom logo (admin only) // Delete custom logo (admin only)
deleteLogo: async (): Promise<BrandingInfo> => { deleteLogo: async (): Promise<BrandingInfo> => {
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo'); const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');

View File

@@ -10,6 +10,8 @@ export interface CouponBatch {
period_days: number; period_days: number;
coupons_total: number; coupons_total: number;
wholesale_price_kopeks: number; wholesale_price_kopeks: number;
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
max_per_user: number;
valid_until: string | null; valid_until: string | null;
is_revoked: boolean; is_revoked: boolean;
created_at: string; created_at: string;
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
period_days: number; period_days: number;
coupons_count: number; coupons_count: number;
wholesale_price_kopeks?: number; wholesale_price_kopeks?: number;
max_per_user?: number;
valid_days?: number; valid_days?: number;
} }
@@ -99,6 +102,12 @@ export const couponsApi = {
}, },
// User: redeem a coupon for the current cabinet user // User: redeem a coupon for the current cabinet user
/** Полное удаление партии (в отличие от revoke, который гасит ссылки) */
deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => {
const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`);
return response.data;
},
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => { redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
const response = await apiClient.post('/cabinet/coupon/redeem', { token }); const response = await apiClient.post('/cabinet/coupon/redeem', { token });
return response.data; return response.data;

View File

@@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse {
created_offers: number; created_offers: number;
user_ids: number[]; user_ids: number[];
target: string | null; target: string | null;
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
notifications_sent: number; notifications_sent: number;
notifications_failed: number; notifications_failed: number;
/** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */
broadcast_id: number | null;
telegram_recipients: number;
}
export interface PromoOfferSegment {
key: string;
count: number;
}
export interface PromoOfferSegmentListResponse {
segments: PromoOfferSegment[];
} }
export interface PromoOfferTemplate { export interface PromoOfferTemplate {
@@ -206,6 +219,12 @@ export const promoOffersApi = {
return response.data; return response.data;
}, },
// Get recipient counts per target segment
getSegments: async (): Promise<PromoOfferSegmentListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/segments');
return response.data;
},
// Get promo offer logs // Get promo offer logs
getLogs: async (params?: { getLogs: async (params?: {
limit?: number; limit?: number;

View File

@@ -13,6 +13,7 @@ import type {
PurchasePreview, PurchasePreview,
AppConfig, AppConfig,
SbpRecurringInfo, SbpRecurringInfo,
LavaRecurringInfo,
} from '../types'; } from '../types';
/** Helper: build query params with optional subscription_id */ /** Helper: build query params with optional subscription_id */
@@ -394,6 +395,49 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ── Recurring (Lava) ──────────────────────────────────────────────────
getLavaRecurring: async (subscriptionId?: number): Promise<LavaRecurringInfo> => {
const response = await apiClient.get<LavaRecurringInfo>(
'/cabinet/subscription/lava-recurrent',
withSubId(subscriptionId),
);
return response.data;
},
enableLavaRecurring: async (
subscriptionId?: number,
): Promise<{ status: string; redirect_url: string | null }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/enable',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
cancelLavaRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/cancel',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
/**
* Оформление подписки на тариф привязкой Lava: первое списание оплачивается
* по возвращённой ссылке и активирует подписку.
*/
purchaseWithLavaRecurring: async (
tariffId: number,
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
const response = await apiClient.post(
'/cabinet/subscription/lava-recurrent/purchase',
{},
{ params: { tariff_id: tariffId } },
);
return response.data;
},
// ── Trial ─────────────────────────────────────────────────────────── // ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -35,6 +35,8 @@ export interface TariffListItem {
show_in_gift: boolean; show_in_gift: boolean;
is_daily: boolean; is_daily: boolean;
daily_price_kopeks: number; daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
traffic_limit_gb: number; traffic_limit_gb: number;
device_limit: number; device_limit: number;
tier_level: number; tier_level: number;
@@ -85,6 +87,8 @@ export interface TariffDetail {
// Дневной тариф // Дневной тариф
is_daily: boolean; is_daily: boolean;
daily_price_kopeks: number; daily_price_kopeks: number;
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
// Внешний сквад Remnawave // Внешний сквад Remnawave
@@ -124,6 +128,8 @@ export interface TariffCreateRequest {
// Дневной тариф // Дневной тариф
is_daily?: boolean; is_daily?: boolean;
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад Remnawave // Внешний сквад Remnawave
@@ -168,6 +174,8 @@ export interface TariffUpdateRequest {
// Дневной тариф // Дневной тариф
is_daily?: boolean; is_daily?: boolean;
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Автопродление Lava: продукт из кабинета Lava
lava_product_id?: string | null;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад Remnawave // Внешний сквад Remnawave

View File

@@ -15,6 +15,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const startVideoInputRef = useRef<HTMLInputElement>(null);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -25,6 +26,12 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getBranding, queryFn: brandingApi.getBranding,
}); });
// Видео стартового меню бота: хранится как Telegram file_id
const { data: startVideo } = useQuery({
queryKey: ['bot-start-video'],
queryFn: brandingApi.getBotStartVideo,
});
const { data: fullscreenSettings } = useQuery({ const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'], queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled, queryFn: brandingApi.getFullscreenEnabled,
@@ -100,6 +107,29 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
}, },
}); });
const uploadStartVideoMutation = useMutation({
mutationFn: brandingApi.uploadBotStartVideo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
},
});
const deleteStartVideoMutation = useMutation({
mutationFn: brandingApi.deleteBotStartVideo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
},
});
const handleStartVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadStartVideoMutation.mutate(file);
}
// Сбрасываем input, чтобы повторный выбор того же файла снова сработал
e.target.value = '';
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@@ -211,6 +241,57 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div> </div>
</div> </div>
{/* Видео стартового меню бота */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="mb-1 text-lg font-semibold text-dark-100">
{t('admin.settings.botStartVideo')}
</h3>
<p className="mb-4 text-sm text-dark-400">{t('admin.settings.botStartVideoDesc')}</p>
<div className="flex flex-wrap items-center gap-3">
<input
ref={startVideoInputRef}
type="file"
accept="video/*"
onChange={handleStartVideoUpload}
className="hidden"
/>
<button
onClick={() => startVideoInputRef.current?.click()}
disabled={uploadStartVideoMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{uploadStartVideoMutation.isPending
? t('common.loading')
: startVideo?.has_video
? t('admin.settings.botStartVideoReplace')
: t('admin.settings.botStartVideoUpload')}
</button>
{startVideo?.has_video && (
<button
onClick={() => deleteStartVideoMutation.mutate()}
disabled={deleteStartVideoMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
>
{t('admin.settings.botStartVideoRemove')}
</button>
)}
<span className="text-sm text-dark-400">
{startVideo?.has_video
? t('admin.settings.botStartVideoActive')
: t('admin.settings.botStartVideoNone')}
</span>
</div>
{uploadStartVideoMutation.isError && (
<div className="mt-3 text-sm text-error-400">
{t('admin.settings.botStartVideoError')}
</div>
)}
</div>
{/* Animated Background Editor */} {/* Animated Background Editor */}
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<BackgroundEditor /> <BackgroundEditor />

View File

@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
buttonStylesApi, buttonStylesApi,
ButtonStylesConfig, type ButtonStylesConfig,
DEFAULT_BUTTON_STYLES, DEFAULT_BUTTON_STYLES,
BUTTON_SECTIONS, BUTTON_SECTIONS,
ButtonSection, type ButtonSection,
BOT_LOCALES, BOT_LOCALES,
} from '../../api/buttonStyles'; } from '../../api/buttonStyles';
import { ChevronDownIcon } from '@/components/icons'; import { ChevronDownIcon } from '@/components/icons';
@@ -18,7 +18,10 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default';
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [ const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
{ value: 'default', colorClass: 'bg-dark-500' }, { value: 'default', colorClass: 'bg-dark-500' },
{ value: 'primary', colorClass: 'bg-accent-500' }, // 'primary' — синяя кнопка Telegram Bot API: свотч фиксированный телеграм-синий,
// а не bg-accent-500 — акцент темы кабинета перекрашивается (например, в оранжевый)
// и не имеет отношения к цвету реальной кнопки в боте.
{ value: 'primary', colorClass: 'bg-[#54a9eb]' },
{ value: 'success', colorClass: 'bg-success-500' }, { value: 'success', colorClass: 'bg-success-500' },
{ value: 'danger', colorClass: 'bg-error-500' }, { value: 'danger', colorClass: 'bg-error-500' },
]; ];
@@ -228,7 +231,7 @@ export function ButtonsTab() {
? 'bg-success-500 text-white' ? 'bg-success-500 text-white'
: cfg.style === 'danger' : cfg.style === 'danger'
? 'bg-error-500 text-white' ? 'bg-error-500 text-white'
: 'bg-accent-500 text-on-accent' : 'bg-[#54a9eb] text-white'
}`} }`}
> >
{t(`admin.buttons.styles.${cfg.style}`)} {t(`admin.buttons.styles.${cfg.style}`)}

View File

@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons';
import { isBroadcastInFlight } from '../../utils/broadcastStatus';
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
export function BroadcastStatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export interface BroadcastDeliveryStatsProps {
status: string;
progressPercent: number;
totalCount: number;
sentCount: number;
blockedCount: number;
failedCount: number;
}
/**
* Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько
* не доехало. Используется и на странице рассылки, и на отправке промопредложения.
*/
export function BroadcastDeliveryStats({
status,
progressPercent,
totalCount,
sentCount,
blockedCount,
failedCount,
}: BroadcastDeliveryStatsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{isBroadcastInFlight(status) && (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-2 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="font-medium text-dark-100">{progressPercent.toFixed(1)}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={totalCount}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={sentCount}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={blockedCount}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={failedCount}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
</div>
);
}
export default BroadcastDeliveryStats;

View File

@@ -6,6 +6,7 @@ import {
retrieveLaunchParams, retrieveLaunchParams,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { blockButtonClass } from './blocks/buttonStyles'; import { blockButtonClass } from './blocks/buttonStyles';
import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner';
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
@@ -19,24 +20,12 @@ function isTelegramMobile(): boolean {
} }
const HAPP_TV_API = 'https://check.happ.su/sendtv'; const HAPP_TV_API = 'https://check.happ.su/sendtv';
const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js';
interface Props { interface Props {
subscriptionUrl: string; subscriptionUrl: string;
isLight: boolean; isLight: boolean;
} }
interface Html5QrcodeInstance {
start: (
cameraIdOrConfig: { facingMode: string } | string,
config: { fps: number; qrbox: { width: number; height: number } },
onSuccess: (decoded: string) => void,
onError: () => void,
) => Promise<void>;
stop: () => Promise<void>;
clear: () => void;
}
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
return; return;
} }
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode.
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; // Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой
const w = window as WindowWithHtml5; // целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете.
if (!w.Html5Qrcode) { const Html5Qrcode = await loadHtml5Qrcode();
const script = document.createElement('script'); if (!Html5Qrcode) {
script.src = HTML5_QRCODE_CDN;
document.head.appendChild(script);
await new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject();
}).catch(() => undefined);
}
if (!w.Html5Qrcode) {
showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
return; return;
} }
setScanning(true); setScanning(true);
const scanner = new w.Html5Qrcode('tv-qr-reader'); const scanner = new Html5Qrcode('tv-qr-reader');
scannerRef.current = scanner; scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } }; const config = { fps: 10, qrbox: { width: 220, height: 220 } };
try { try {

View File

@@ -8,6 +8,7 @@ import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { usePlatform } from '../../../platform'; import { usePlatform } from '../../../platform';
import { openPaymentUrl } from '../../../utils/openPaymentUrl'; import { openPaymentUrl } from '../../../utils/openPaymentUrl';
import { getMonthlyPriceKopeks } from '../../../utils/pricing';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt'; import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff, TariffPeriod } from '../../../types'; import type { Tariff, TariffPeriod } from '../../../types';
@@ -35,6 +36,8 @@ export interface TariffPurchaseFormProps {
balanceKopeks: number | undefined; balanceKopeks: number | undefined;
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */ /** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
sbpPurchaseEnabled?: boolean; sbpPurchaseEnabled?: boolean;
/** Оформление привязкой Lava доступно — показать вторую CTA. */
lavaPurchaseEnabled?: boolean;
onBack: () => void; onBack: () => void;
} }
@@ -43,6 +46,7 @@ export function TariffPurchaseForm({
subscriptionId, subscriptionId,
balanceKopeks, balanceKopeks,
sbpPurchaseEnabled = false, sbpPurchaseEnabled = false,
lavaPurchaseEnabled = false,
onBack, onBack,
}: TariffPurchaseFormProps) { }: TariffPurchaseFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -142,6 +146,47 @@ export function TariffPurchaseForm({
</> </>
); );
const lavaPurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseWithLavaRecurring(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: ['lava-recurring', data.subscription_id] });
navigate('/subscriptions', { replace: true });
},
});
const lavaPurchaseButton = lavaPurchaseEnabled && (
<>
<button
onClick={() => lavaPurchaseMutation.mutate()}
disabled={lavaPurchaseMutation.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"
>
{lavaPurchaseMutation.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.lavaRecurring.purchaseButton')
)}
</button>
<div className="mt-1.5 text-center text-[11px] text-dark-500">
{t('subscription.lavaRecurring.purchaseHint')}
</div>
{lavaPurchaseMutation.isError && (
<div className="mt-2 text-center text-sm text-error-400">
{getErrorMessage(lavaPurchaseMutation.error)}
</div>
)}
</>
);
// Smooth scroll the form into view when first mounted. // Smooth scroll the form into view when first mounted.
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
@@ -240,6 +285,7 @@ export function TariffPurchaseForm({
</button> </button>
{sbpPurchaseButton} {sbpPurchaseButton}
{lavaPurchaseButton}
{purchaseMutation.isError && {purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && ( !getInsufficientBalanceError(purchaseMutation.error) && (
@@ -279,10 +325,7 @@ export function TariffPurchaseForm({
const displayDiscount = promoPeriod.percent; const displayDiscount = promoPeriod.percent;
const displayOriginal = promoPeriod.original; const displayOriginal = promoPeriod.original;
const displayPrice = promoPeriod.price; const displayPrice = promoPeriod.price;
const displayPerMonth = const displayPerMonth = getMonthlyPriceKopeks(displayPrice, period.days);
displayPrice !== period.price_kopeks
? Math.round(displayPrice / Math.max(1, period.days / 30))
: period.price_per_month_kopeks;
return ( return (
<button <button
@@ -317,9 +360,11 @@ export function TariffPurchaseForm({
</span> </span>
)} )}
</div> </div>
<div className="mt-1 text-xs text-dark-500"> {displayPerMonth !== null && (
{formatPrice(displayPerMonth)}/{t('subscription.month')} <div className="mt-1 text-xs text-dark-500">
</div> {formatPrice(displayPerMonth)}/{t('subscription.month')}
</div>
)}
</button> </button>
); );
})} })}
@@ -666,6 +711,7 @@ export function TariffPurchaseForm({
</button> </button>
{sbpPurchaseButton} {sbpPurchaseButton}
{lavaPurchaseButton}
</> </>
); );
})()} })()}

View File

@@ -701,6 +701,33 @@
"purchaseButton": "⚡ Subscribe with SBP auto-payment", "purchaseButton": "⚡ Subscribe with SBP auto-payment",
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence." "purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
}, },
"lavaRecurring": {
"title": "Lava auto-renewal",
"connect": "Connect",
"payFirst": "Pay the first invoice",
"cancel": "Disable",
"confirmCancel": "Disable Lava auto-renewal? The subscription will no longer renew automatically.",
"cancelled": "Lava auto-renewal disabled",
"enableError": "Could not enable Lava auto-renewal",
"cancelError": "Could not disable Lava auto-renewal",
"statusPending": "Waiting for the first invoice to be paid",
"statusActive": "Active",
"statusPastDue": "Payment failed — please try again",
"nextCharge": "next charge on {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "{{amount}} ₽ every {{days}} days",
"period": {
"day": "daily",
"week": "weekly",
"month": "monthly",
"quarter": "quarterly",
"halfYear": "every six months",
"year": "yearly"
},
"autopayHint": "The subscription will renew automatically via Lava",
"purchaseButton": "⚡ Subscribe with Lava auto-renewal",
"purchaseHint": "The first invoice is paid via a Lava link; renewals are then charged automatically on the products schedule."
},
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?", "confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge", "dailyAutoCharge": "Daily auto-charge",
@@ -1930,7 +1957,7 @@
}, },
"broadcasts": { "broadcasts": {
"title": "Broadcasts", "title": "Broadcasts",
"subtitle": "History and management", "subtitle": "Mass sending of messages to users",
"create": "Create Broadcast", "create": "Create Broadcast",
"empty": "No broadcasts", "empty": "No broadcasts",
"selectFilter": "Select audience", "selectFilter": "Select audience",
@@ -2293,7 +2320,15 @@
"offlineConv": "Offline Conversions", "offlineConv": "Offline Conversions",
"apiKey": "API Key", "apiKey": "API Key",
"legalFooter": "Legal Footer", "legalFooter": "Legal Footer",
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page" "legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page",
"botStartVideo": "Bot start-menu video",
"botStartVideoDesc": "The bot attaches this video to the /start message instead of the logo image. The file is uploaded to Telegram once — we only keep its identifier.",
"botStartVideoUpload": "Upload video",
"botStartVideoReplace": "Replace video",
"botStartVideoRemove": "Remove",
"botStartVideoActive": "Video is set",
"botStartVideoNone": "No video — the menu uses the logo image",
"botStartVideoError": "Could not upload the video"
}, },
"bulkActions": { "bulkActions": {
"title": "Bulk Actions", "title": "Bulk Actions",
@@ -2740,7 +2775,9 @@
"periodsRequired": "Add at least one period", "periodsRequired": "Add at least one period",
"dailyPriceRequired": "Enter a price per day greater than 0", "dailyPriceRequired": "Enter a price per day greater than 0",
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)" "trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
} },
"lavaProductLabel": "Lava product (auto-renewal)",
"lavaProductDesc": "Product UUID from the Lava dashboard: price and charge period are configured there. Empty — auto-renewal is unavailable for this tariff."
}, },
"servers": { "servers": {
"title": "Squad Management", "title": "Squad Management",
@@ -3131,7 +3168,9 @@
"validDaysHint": "0 or empty — perpetual", "validDaysHint": "0 or empty — perpetual",
"create": "Create batch", "create": "Create batch",
"creating": "Creating…", "creating": "Creating…",
"cancel": "Cancel" "cancel": "Cancel",
"maxPerUser": "Per-user limit",
"maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests."
}, },
"validation": { "validation": {
"nameRequired": "Enter the batch name", "nameRequired": "Enter the batch name",
@@ -3156,7 +3195,8 @@
"validUntil": "Valid until", "validUntil": "Valid until",
"perpetual": "Perpetual", "perpetual": "Perpetual",
"createdAt": "Created", "createdAt": "Created",
"linksTitle": "Active links ({{count}})" "linksTitle": "Active links ({{count}})",
"maxPerUser": "Per user"
}, },
"revoke": { "revoke": {
"button": "Revoke unredeemed ({{count}})", "button": "Revoke unredeemed ({{count}})",
@@ -3169,7 +3209,16 @@
"errors": { "errors": {
"loadFailed": "Batch not found", "loadFailed": "Batch not found",
"createFailed": "Failed to create the batch", "createFailed": "Failed to create the batch",
"revokeFailed": "Failed to revoke the batch" "revokeFailed": "Failed to revoke the batch",
"deleteFailed": "Could not delete the batch"
},
"delete": {
"button": "🗑 Delete batch",
"confirmTitle": "Delete the batch?",
"confirmText": "{{count}} coupons will be deleted along with the batch itself.",
"redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.",
"confirm": "Delete",
"success": "Batch deleted ({{count}} coupons)"
} }
}, },
"promocodes": { "promocodes": {
@@ -3807,7 +3856,9 @@
"sendError": "Failed to send offer", "sendError": "Failed to send offer",
"notificationsSent": "Notifications sent", "notificationsSent": "Notifications sent",
"offersCreated": "Offers created", "offersCreated": "Offers created",
"notificationsFailed": "(failed: {{count}})" "notificationsFailed": "(failed: {{count}})",
"deliveryTitle": "Telegram delivery",
"openAsBroadcast": "Open as broadcast"
}, },
"notFound": "Template not found", "notFound": "Template not found",
"noActiveTemplates": "No active templates", "noActiveTemplates": "No active templates",
@@ -5453,7 +5504,15 @@
"shareModalActivateVia": "Activate via bot:", "shareModalActivateVia": "Activate via bot:",
"shareModalActivateViaCabinet": "Or via website:", "shareModalActivateViaCabinet": "Or via website:",
"copyMessage": "Copy message", "copyMessage": "Copy message",
"shareToastCopied": "Message copied" "shareToastCopied": "Message copied",
"qrHint": "Scan to activate the gift",
"scanButton": "📷 Scan QR",
"scanInProgress": "Scanning…",
"scanCancel": "Stop scanning",
"scanDescription": "Point the camera at the gift QR code",
"scanNotRecognized": "No gift code in this QR",
"scanError": "Could not scan",
"scanNoCamera": "Camera unavailable — enter the code manually"
}, },
"news": { "news": {
"title": "News & Updates", "title": "News & Updates",

View File

@@ -276,7 +276,7 @@
"traffic": "ترافیک", "traffic": "ترافیک",
"devices": "دستگاه‌ها", "devices": "دستگاه‌ها",
"expiredDate": "منقضی شده", "expiredDate": "منقضی شده",
"activeUntil": "منقضی شده — تا {{date}}" "activeUntil": "فعال تا"
}, },
"deviceLimitReached": "برای افزودن دستگاه جدید، دستگاه‌های فعلی را قطع کنید", "deviceLimitReached": "برای افزودن دستگاه جدید، دستگاه‌های فعلی را قطع کنید",
"suspended": { "suspended": {
@@ -287,9 +287,9 @@
"showAll": "نمایش همه", "showAll": "نمایش همه",
"subscriptions": "اشتراک‌ها", "subscriptions": "اشتراک‌ها",
"connectDevice": "اتصال دستگاه", "connectDevice": "اتصال دستگاه",
"devicesConnectedUnlimited": "{{count}} دستگاه متصل", "devicesConnectedUnlimited": "{{used}} دستگاه متصل · نامحدود",
"devicesOfMax": "{{used}} از {{max}}", "devicesOfMax": "{{used}} از {{max}}",
"maxUsage": "حداکثر", "maxUsage": "حداکثر {{amount}}",
"remaining": "باقی‌مانده", "remaining": "باقی‌مانده",
"stats": { "stats": {
"balance": "موجودی", "balance": "موجودی",
@@ -298,9 +298,9 @@
"tariff": "تعرفه", "tariff": "تعرفه",
"trafficUsageTitle": "مصرف ترافیک", "trafficUsageTitle": "مصرف ترافیک",
"trialOffer": { "trialOffer": {
"freeDesc": "{{days}} روز رایگان آزمایش کنید بدون پرداخت", "freeDesc": "وی‌پی‌ان ما را رایگان امتحان کنید: بدون هیچ تعهدی",
"freeTitle": "دوره آزمایشی رایگان", "freeTitle": "دوره آزمایشی رایگان",
"paidDesc": "{{days}} روز آزمایشی فقط با {{price}}", "paidDesc": "با یک اشتراک آزمایشی شروع کنید",
"paidTitle": "دوره آزمایشی پرداختی" "paidTitle": "دوره آزمایشی پرداختی"
}, },
"unlimited": "نامحدود", "unlimited": "نامحدود",
@@ -458,7 +458,7 @@
"newDeviceLimit": "محدودیت جدید دستگاه", "newDeviceLimit": "محدودیت جدید دستگاه",
"reduce": "کاهش", "reduce": "کاهش",
"reduceDevices": "کاهش تعداد دستگاه‌ها", "reduceDevices": "کاهش تعداد دستگاه‌ها",
"reduceDevicesDescription": حدودیت دستگاه‌های خود را تنظیم کنید. در صورت لزوم ابتدا دستگاه‌های اضافه را قطع کنید", "reduceDevicesDescription": ی‌توانید تعداد دستگاه‌ها را تا حداقل مجاز در تعرفه کاهش دهید",
"reduceDevicesTitle": "کاهش تعداد دستگاه‌ها", "reduceDevicesTitle": "کاهش تعداد دستگاه‌ها",
"reduceUnavailable": "در حال حاضر کاهش تعداد دستگاه‌ها امکان‌پذیر نیست", "reduceUnavailable": "در حال حاضر کاهش تعداد دستگاه‌ها امکان‌پذیر نیست",
"reducing": "در حال کاهش…" "reducing": "در حال کاهش…"
@@ -600,13 +600,40 @@
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP", "purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند." "purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند."
}, },
"lavaRecurring": {
"title": "تمدید خودکار Lava",
"connect": "اتصال",
"payFirst": "پرداخت صورتحساب اول",
"cancel": "غیرفعال‌سازی",
"confirmCancel": "تمدید خودکار Lava غیرفعال شود؟ اشتراک دیگر به‌طور خودکار تمدید نمی‌شود.",
"cancelled": "تمدید خودکار Lava غیرفعال شد",
"enableError": "فعال‌سازی تمدید خودکار Lava ناموفق بود",
"cancelError": "غیرفعال‌سازی تمدید خودکار Lava ناموفق بود",
"statusPending": "در انتظار پرداخت صورتحساب اول",
"statusActive": "فعال",
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
"nextCharge": "برداشت بعدی {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "هر {{days}} روز {{amount}} ₽",
"period": {
"day": "روزانه",
"week": "هفتگی",
"month": "ماهانه",
"quarter": "هر سه ماه",
"halfYear": "هر شش ماه",
"year": "سالانه"
},
"autopayHint": "اشتراک به‌طور خودکار از طریق Lava تمدید می‌شود",
"purchaseButton": "⚡ ثبت‌نام با تمدید خودکار Lava",
"purchaseHint": "صورتحساب اول از طریق لینک Lava پرداخت می‌شود؛ سپس تمدیدها بر اساس دوره محصول به‌طور خودکار کسر می‌شوند."
},
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟", "confirmDelete": "اشتراک حذف شود؟",
"cta": { "cta": {
"renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید", "renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
"activeHint": "اشتراک فعال است — می‌توانید دستگاه‌ها را متصل کرده و از VPN استفاده کنید", "activeHint": "تمدید و تغییر تعرفه",
"expiredHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید", "expiredHint": "یک تعرفه انتخاب و پرداخت کنید",
"trialHint": "شما در دوره آزمایشی هستید — برای دسترسی به همه امکانات به تعرفه پرداختی ارتقا دهید" "trialHint": "ترافیک و دستگاه بیشتر"
}, },
"dailyAutoCharge": "برداشت خودکار روزانه", "dailyAutoCharge": "برداشت خودکار روزانه",
"defaultName": "اشتراک", "defaultName": "اشتراک",
@@ -626,16 +653,16 @@
"statusTrial": "آزمایشی", "statusTrial": "آزمایشی",
"daysShort": "روز", "daysShort": "روز",
"expiredBanner": { "expiredBanner": {
"selectTariff": "تعرفه را انتخاب کنید", "selectTariff": "اشتراک شما منقضی شده است. برای بازیابی دسترسی به VPN، یک تعرفه را از پایین انتخاب کنید.",
"title": "اشتراک منقضی شده است" "title": "اشتراک منقضی شده است"
}, },
"trialInfo": { "trialInfo": {
"description": "{{days}} روز سرویس ما را رایگان امتحان کنید", "description": "شما در حال استفاده از نسخهٔ آزمایشی سرویس هستید. برای دسترسی کامل، یک تعرفه را در پایین انتخاب کنید.",
"remaining": "{{count}} روز باقی‌مانده", "remaining": "باقی‌مانده",
"title": "دوره آزمایشی" "title": "دوره آزمایشی"
}, },
"trialUpgrade": { "trialUpgrade": {
"description": "برای دسترسی به همه امکانات و ادامه استفاده به اشتراک پرداختی ارتقا دهید", "description": "دوره آزمایشی شما به‌زودی به پایان می‌رسد. برای ادامه استفاده از VPN بدون محدودیت، یک تعرفه مناسب انتخاب کنید.",
"title": "ارتقای اشتراک" "title": "ارتقای اشتراک"
} }
}, },
@@ -1008,7 +1035,7 @@
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.", "noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.",
"selectSubscription": "ابتدا یک اشتراک انتخاب کنید" "selectSubscription": "ابتدا یک اشتراک انتخاب کنید"
}, },
"payWithStars": "پرداخت {stars} Stars", "payWithStars": "پرداخت Stars",
"processingPayment": "در حال پردازش...", "processingPayment": "در حال پردازش...",
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
@@ -1564,7 +1591,7 @@
}, },
"broadcasts": { "broadcasts": {
"title": "نشریات", "title": "نشریات",
"subtitle": "تاریخچه و مدیریت", "subtitle": "ارسال گروهی پیام به کاربران",
"create": "ایجاد پیام", "create": "ایجاد پیام",
"empty": "هیچ نشریه‌ای نیست", "empty": "هیچ نشریه‌ای نیست",
"selectFilter": "انتخاب مخاطبین", "selectFilter": "انتخاب مخاطبین",
@@ -1652,46 +1679,46 @@
"telegramSection": "ارسال انبوه تلگرام" "telegramSection": "ارسال انبوه تلگرام"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Pinned Messages", "title": "پیام‌های سنجاق‌شده",
"subtitle": "Manage pinned messages for users", "subtitle": "مدیریت پیام‌های سنجاق‌شده برای کاربران",
"create": "Create", "create": "ایجاد",
"empty": "No pinned messages", "empty": "هیچ پیام سنجاق‌شده‌ای وجود ندارد",
"content": "Message text", "content": "متن پیام",
"contentPlaceholder": "Enter pinned message text...", "contentPlaceholder": "متن پیام سنجاق‌شده را وارد کنید...",
"noContent": "No text", "noContent": "بدون متن",
"media": "Media file", "media": "فایل رسانه",
"addMedia": "Add media", "addMedia": "افزودن رسانه",
"uploading": "Uploading...", "uploading": "در حال بارگذاری...",
"settings": "Settings", "settings": "تنظیمات",
"sendBeforeMenu": "Send before menu", "sendBeforeMenu": "ارسال قبل از منو",
"sendOnEveryStart": "Send on every start", "sendOnEveryStart": "ارسال در هر بار شروع",
"broadcast": "Broadcast", "broadcast": "ارسال همگانی",
"broadcastOnCreate": "Broadcast on create", "broadcastOnCreate": "ارسال همگانی هنگام ایجاد",
"broadcastConfirm": "Broadcast pinned message to all active users?", "broadcastConfirm": "پیام سنجاق‌شده برای همه کاربران فعال ارسال شود؟",
"broadcastToAll": "Broadcast to all", "broadcastToAll": "ارسال به همه",
"activate": "Activate", "activate": "فعال‌سازی",
"deactivate": "Deactivate", "deactivate": "غیرفعال‌سازی",
"unpin": "Unpin", "unpin": "برداشتن سنجاق",
"unpinAll": "Unpin for everyone", "unpinAll": "برداشتن سنجاق برای همه",
"unpinConfirm": "Unpin message for all users?", "unpinConfirm": "سنجاق پیام برای همه کاربران برداشته شود؟",
"delete": "Delete", "delete": "حذف",
"deleteConfirm": "Delete this pinned message?", "deleteConfirm": "این پیام سنجاق‌شده حذف شود؟",
"active": "Active", "active": "فعال",
"inactive": "Inactive", "inactive": "غیرفعال",
"prev": "Previous", "prev": "قبلی",
"next": "Next", "next": "بعدی",
"createSuccess": "Pinned message created", "createSuccess": "پیام سنجاق‌شده ایجاد شد",
"updateSuccess": "Message updated", "updateSuccess": "پیام به‌روزرسانی شد",
"deleteSuccess": "Message deleted", "deleteSuccess": "پیام حذف شد",
"activateSuccess": "Message activated", "activateSuccess": "پیام فعال شد",
"deactivateSuccess": "Message deactivated", "deactivateSuccess": "پیام غیرفعال شد",
"broadcastSuccess": "Broadcast completed", "broadcastSuccess": "ارسال همگانی تکمیل شد",
"unpinSuccess": "Messages unpinned", "unpinSuccess": "سنجاق پیام‌ها برداشته شد",
"sentCount": "Sent", "sentCount": "ارسال‌شده",
"failedCount": "Failed", "failedCount": "ناموفق",
"unpinnedCount": "Unpinned", "unpinnedCount": "سنجاق‌برداشته",
"editMessage": "Edit", "editMessage": "ویرایش",
"cantDeleteActive": "Cannot delete active message" "cantDeleteActive": "نمی‌توان پیام فعال را حذف کرد"
}, },
"channelSubscriptions": { "channelSubscriptions": {
"title": "کانال‌های اجباری", "title": "کانال‌های اجباری",
@@ -1956,7 +1983,15 @@
"offlineConv": "تبدیل‌های آفلاین", "offlineConv": "تبدیل‌های آفلاین",
"apiKey": "کلید API", "apiKey": "کلید API",
"legalFooter": "پاورقی حقوقی", "legalFooter": "پاورقی حقوقی",
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود" "legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداخت‌های مکرر در پایین صفحه ورود",
"botStartVideo": "ویدیوی منوی شروع ربات",
"botStartVideoDesc": "ربات این ویدیو را به‌جای تصویر لوگو به پیام /start پیوست می‌کند. فایل یک‌بار در تلگرام بارگذاری می‌شود و ما فقط شناسه آن را نگه می‌داریم.",
"botStartVideoUpload": "بارگذاری ویدیو",
"botStartVideoReplace": "جایگزینی ویدیو",
"botStartVideoRemove": "حذف",
"botStartVideoActive": "ویدیو تنظیم شده است",
"botStartVideoNone": "ویدیویی تنظیم نشده — منو از تصویر لوگو استفاده می‌کند",
"botStartVideoError": "بارگذاری ویدیو ناموفق بود"
}, },
"buttons": { "buttons": {
"color": "رنگ دکمه", "color": "رنگ دکمه",
@@ -2005,7 +2040,7 @@
"builtinButtons": "بخش‌های داخلی", "builtinButtons": "بخش‌های داخلی",
"resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.", "resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.",
"buttonTextPlaceholder": "متن دکمه", "buttonTextPlaceholder": "متن دکمه",
"customLabelsHint": "متن دکمه برای هر زبان ربات", "customLabelsHint": "خالی = عنوان پیش‌فرض",
"moveUp": "انتقال به بالا", "moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین", "moveDown": "انتقال به پایین",
"openIn": "باز کردن در", "openIn": "باز کردن در",
@@ -2278,7 +2313,9 @@
"periodsRequired": "حداقل یک دوره اضافه کنید", "periodsRequired": "حداقل یک دوره اضافه کنید",
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید", "dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)" "trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
} },
"lavaProductLabel": "محصول Lava (تمدید خودکار)",
"lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم می‌شود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست."
}, },
"servers": { "servers": {
"title": "مدیریت اسکوادها", "title": "مدیریت اسکوادها",
@@ -2625,10 +2662,10 @@
"programEnabled": "برنامه معرفی فعال است", "programEnabled": "برنامه معرفی فعال است",
"programEnabledDesc": "تمام عملکرد ارجاع را فعال می‌کند", "programEnabledDesc": "تمام عملکرد ارجاع را فعال می‌کند",
"requisitesText": "متن اطلاعات حساب", "requisitesText": "متن اطلاعات حساب",
"requisitesTextDesc": "در فرم درخواست همکاری به کاربر نمایش داده می‌شود", "requisitesTextDesc": "متن سفارشی برای توضیح به کاربران که هنگام برداشت چه اطلاعات پرداختی را وارد کنند",
"requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...", "requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...",
"withdrawalEnabled": "برداشت مجاز است", "withdrawalEnabled": "برداشت مجاز است",
"withdrawalEnabledDesc": "به کاربران اجازه می‌دهد درخواست برداشت بدهند" "withdrawalEnabledDesc": "اجازه دادن به همکاران برای درخواست برداشت موجودی معرفی"
}, },
"settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود", "settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود",
"settingsSection": { "settingsSection": {
@@ -3305,7 +3342,9 @@
"notificationsFailed": "(ناموفق: {{count}})", "notificationsFailed": "(ناموفق: {{count}})",
"sendError": "ارسال پیشنهاد ناموفق بود", "sendError": "ارسال پیشنهاد ناموفق بود",
"notificationsSent": "اعلان‌های ارسال شده", "notificationsSent": "اعلان‌های ارسال شده",
"offersCreated": "پیشنهادهای ایجاد شده" "offersCreated": "پیشنهادهای ایجاد شده",
"deliveryTitle": "تحویل در تلگرام",
"openAsBroadcast": "نمایش به‌عنوان ارسال گروهی"
}, },
"notFound": "قالب یافت نشد", "notFound": "قالب یافت نشد",
"noActiveTemplates": "قالب فعالی وجود ندارد", "noActiveTemplates": "قالب فعالی وجود ندارد",
@@ -3398,7 +3437,7 @@
"usersOnlineCount": "{{count}} آنلاین" "usersOnlineCount": "{{count}} آنلاین"
}, },
"overview": { "overview": {
"bandwidth": "پهنای باند لحظه‌ای", "bandwidth": "ترافیک ورودی‌ها",
"connections": "اتصالات", "connections": "اتصالات",
"cpu": "هسته‌های CPU", "cpu": "هسته‌های CPU",
"download": "دانلود", "download": "دانلود",
@@ -4940,40 +4979,48 @@
}, },
"activateButton": "فعال‌سازی", "activateButton": "فعال‌سازی",
"activateCodePlaceholder": "کد هدیه را وارد کنید", "activateCodePlaceholder": "کد هدیه را وارد کنید",
"activatedBy": "توسط {{name}} فعال شد", "activatedBy": "توسط {{username}} فعال شد",
"activateDescription": "برای فعال‌سازی اشتراک، کد هدیه دریافتی را وارد کنید", "activateDescription": "برای فعال‌سازی اشتراک، کد هدیه دریافتی را وارد کنید",
"activatedGiftsTitle": "هدایای فعال‌شده", "activatedGiftsTitle": "هدایای فعال‌شده",
"activateError": "فعال‌سازی هدیه ناموفق بود", "activateError": "فعال‌سازی هدیه ناموفق بود",
"activateSelfError": "نمی‌توانید هدیه خودتان را فعال کنید", "activateSelfError": "نمی‌توانید هدیه خودتان را فعال کنید",
"activateSuccess": "هدیه فعال شد!", "activateSuccess": "هدیه فعال شد!",
"activateSuccessDesc": "اشتراک به حساب شما افزوده شد", "activateSuccessDesc": "{{tariff}}: {{days}} روز به اشتراک شما اضافه شد",
"activateTitle": "فعال‌سازی هدیه", "activateTitle": "فعال‌سازی هدیه",
"activeGiftsTitle": "هدایای فعال", "activeGiftsTitle": "هدایای فعال",
"codeLabel": "کد هدیه", "codeLabel": "کد هدیه",
"codeReadyTitle": "کد هدیه شما آماده است", "codeReadyTitle": "کد هدیه شما آماده است",
"copyMessage": "کپی پیام", "copyMessage": "کپی پیام",
"daysShort": "روز", "daysShort": "روز",
"deviceCount": "تعداد دستگاهها", "deviceCount": "{{count}} دستگاه",
"devicesShort": "دستگاه", "devicesShort": "دستگاه",
"gbShort": "گیگابایت", "gbShort": "گیگابایت",
"myGiftsEmpty": "هنوز هدیه‌ای ندارید", "myGiftsEmpty": "هنوز هدیه‌ای ندارید",
"myGiftsEmptyDesc": "هدایایی که ارسال یا دریافت می‌کنید اینجا نمایش داده می‌شوند", "myGiftsEmptyDesc": "برای یک دوست هدیه بخرید یا کد دریافتی را فعال کنید",
"pageTitle": "هدایا", "pageTitle": "هدایا",
"receivedGiftsTitle": "هدایای دریافت‌شده", "receivedGiftsTitle": "هدایای دریافت‌شده",
"selectPeriod": "دوره را انتخاب کنید", "selectPeriod": "دوره را انتخاب کنید",
"selectTariff": "تعرفه را انتخاب کنید", "selectTariff": "تعرفه را انتخاب کنید",
"sentTo": "به {{name}} ارسال شد", "sentTo": "به {{recipient}} ارسال شد",
"shareGift": "به اشتراک گذاشتن هدیه", "shareGift": "به اشتراک گذاشتن هدیه",
"shareModalActivateVia": "از طریق فعال‌سازی", "shareModalActivateVia": "فعال‌سازی از طریق ربات:",
"shareModalActivateViaCabinet": "از طریق کابین", "shareModalActivateViaCabinet": "از طریق کابین",
"shareText": "برای شما هدیه‌ای از Bedolaga VPN دارم: {{code}}", "shareText": "برای تو یک هدیهٔ Bedolaga VPN دارم، از همین‌جا فعالش کن:",
"shareToastCopied": "لینک کپی شد", "shareToastCopied": "لینک کپی شد",
"statusActivated": "فعال شده", "statusActivated": "فعال شده",
"statusAvailable": "قابل فعال‌سازی", "statusAvailable": "قابل فعال‌سازی",
"tabActivate": "فعال‌سازی", "tabActivate": "فعال‌سازی",
"tabBuy": "خرید", "tabBuy": "خرید",
"tabMyGifts": "هدایای من", "tabMyGifts": "هدایای من",
"unlimitedTraffic": "ترافیک نامحدود" "unlimitedTraffic": "ترافیک نامحدود",
"qrHint": "برای فعال‌سازی هدیه اسکن کنید",
"scanButton": "📷 اسکن QR",
"scanInProgress": "در حال اسکن…",
"scanCancel": "توقف اسکن",
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
"scanNotRecognized": "در این QR کد هدیه‌ای نیست",
"scanError": "اسکن ناموفق بود",
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
}, },
"news": { "news": {
"title": "اخبار و به‌روزرسانی‌ها", "title": "اخبار و به‌روزرسانی‌ها",

View File

@@ -720,6 +720,33 @@
"purchaseButton": "⚡ Оформить с автооплатой СБП", "purchaseButton": "⚡ Оформить с автооплатой СБП",
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа." "purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
}, },
"lavaRecurring": {
"title": "Автопродление Lava",
"connect": "Подключить",
"payFirst": "Оплатить первый счёт",
"cancel": "Отключить",
"confirmCancel": "Отключить автопродление Lava? Подписка больше не будет продлеваться автоматически.",
"cancelled": "Автопродление Lava отключено",
"enableError": "Не удалось подключить автопродление Lava",
"cancelError": "Не удалось отменить автопродление Lava",
"statusPending": "Ожидает оплаты первого счёта",
"statusActive": "Активно",
"statusPastDue": "Платёж не прошёл — попробуйте снова",
"nextCharge": "следующее списание {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "{{amount}} ₽ раз в {{days}} дн.",
"period": {
"day": "ежедневно",
"week": "еженедельно",
"month": "ежемесячно",
"quarter": "раз в квартал",
"halfYear": "раз в полгода",
"year": "ежегодно"
},
"autopayHint": "Подписка будет автоматически продлеваться через Lava",
"purchaseButton": "⚡ Оформить с автооплатой Lava",
"purchaseHint": "Первое списание оплачивается по ссылке Lava; дальше продления списываются автоматически по периоду продукта."
},
"backToList": "К списку подписок", "backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?", "confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание", "dailyAutoCharge": "Ежедневное списание",
@@ -2796,7 +2823,15 @@
"offlineConv": "Офлайн-конверсии", "offlineConv": "Офлайн-конверсии",
"apiKey": "API-ключ", "apiKey": "API-ключ",
"legalFooter": "Юридический футер", "legalFooter": "Юридический футер",
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа" "legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа",
"botStartVideo": "Видео стартового меню бота",
"botStartVideoDesc": "Бот прикрепит это видео к сообщению /start вместо картинки-логотипа. Файл разово отправляется в Telegram — храним только его идентификатор.",
"botStartVideoUpload": "Загрузить видео",
"botStartVideoReplace": "Заменить видео",
"botStartVideoRemove": "Удалить",
"botStartVideoActive": "Видео подключено",
"botStartVideoNone": "Видео не задано — меню с картинкой-логотипом",
"botStartVideoError": "Не удалось загрузить видео"
}, },
"buttons": { "buttons": {
"color": "Цвет кнопки", "color": "Цвет кнопки",
@@ -3129,7 +3164,9 @@
"periodsRequired": "Добавьте хотя бы один период", "periodsRequired": "Добавьте хотя бы один период",
"dailyPriceRequired": "Укажите цену за день больше 0", "dailyPriceRequired": "Укажите цену за день больше 0",
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)" "trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
} },
"lavaProductLabel": "Продукт Lava (автопродление)",
"lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно."
}, },
"servers": { "servers": {
"title": "Управление сквадами", "title": "Управление сквадами",
@@ -3523,7 +3560,9 @@
"validDaysHint": "0 или пусто — бессрочно", "validDaysHint": "0 или пусто — бессрочно",
"create": "Создать партию", "create": "Создать партию",
"creating": "Создание…", "creating": "Создание…",
"cancel": "Отмена" "cancel": "Отмена",
"maxPerUser": "Лимит на пользователя",
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
}, },
"validation": { "validation": {
"nameRequired": "Укажите название партии", "nameRequired": "Укажите название партии",
@@ -3548,7 +3587,8 @@
"validUntil": "Действует до", "validUntil": "Действует до",
"perpetual": "Бессрочно", "perpetual": "Бессрочно",
"createdAt": "Создана", "createdAt": "Создана",
"linksTitle": "Активные ссылки ({{count}} шт.)" "linksTitle": "Активные ссылки ({{count}} шт.)",
"maxPerUser": "На пользователя"
}, },
"revoke": { "revoke": {
"button": "Отозвать непогашенные ({{count}})", "button": "Отозвать непогашенные ({{count}})",
@@ -3561,7 +3601,16 @@
"errors": { "errors": {
"loadFailed": "Партия не найдена", "loadFailed": "Партия не найдена",
"createFailed": "Не удалось создать партию", "createFailed": "Не удалось создать партию",
"revokeFailed": "Не удалось отозвать партию" "revokeFailed": "Не удалось отозвать партию",
"deleteFailed": "Не удалось удалить партию"
},
"delete": {
"button": "🗑 Удалить партию",
"confirmTitle": "Удалить партию?",
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
"confirm": "Удалить",
"success": "Партия удалена (купонов: {{count}})"
} }
}, },
"promocodes": { "promocodes": {
@@ -4208,7 +4257,9 @@
"sendError": "Не удалось отправить предложение", "sendError": "Не удалось отправить предложение",
"notificationsSent": "Отправлено уведомлений", "notificationsSent": "Отправлено уведомлений",
"offersCreated": "Создано офферов", "offersCreated": "Создано офферов",
"notificationsFailed": "(не отправлено: {{count}})" "notificationsFailed": "(не отправлено: {{count}})",
"deliveryTitle": "Доставка в Telegram",
"openAsBroadcast": "Открыть как рассылку"
}, },
"notFound": "Шаблон не найден", "notFound": "Шаблон не найден",
"noActiveTemplates": "Нет активных шаблонов", "noActiveTemplates": "Нет активных шаблонов",
@@ -6007,7 +6058,15 @@
"shareModalActivateVia": "Активировать через бота:", "shareModalActivateVia": "Активировать через бота:",
"shareModalActivateViaCabinet": "Или через сайт:", "shareModalActivateViaCabinet": "Или через сайт:",
"copyMessage": "Скопировать сообщение", "copyMessage": "Скопировать сообщение",
"shareToastCopied": "Сообщение скопировано" "shareToastCopied": "Сообщение скопировано",
"qrHint": "Отсканируйте, чтобы активировать подарок",
"scanButton": "📷 Отсканировать QR",
"scanInProgress": "Сканирование…",
"scanCancel": "Остановить сканирование",
"scanDescription": "Наведите камеру на QR-код подарка",
"scanNotRecognized": "В QR-коде нет кода подарка",
"scanError": "Не удалось отсканировать",
"scanNoCamera": "Камера недоступна — введите код вручную"
}, },
"news": { "news": {
"title": "Новости и обновления", "title": "Новости и обновления",

View File

@@ -20,7 +20,7 @@
"delete": "删除", "delete": "删除",
"currency": "¥", "currency": "¥",
"copy": "复制", "copy": "复制",
"all": "All", "all": "全部",
"add": "添加", "add": "添加",
"refresh": "刷新", "refresh": "刷新",
"retry": "重试", "retry": "重试",
@@ -276,7 +276,7 @@
"traffic": "流量", "traffic": "流量",
"devices": "设备", "devices": "设备",
"expiredDate": "过期时间", "expiredDate": "过期时间",
"activeUntil": "已过期 — 至 {{date}}" "activeUntil": "有效期至"
}, },
"deviceLimitReached": "断开设备以添加新设备", "deviceLimitReached": "断开设备以添加新设备",
"suspended": { "suspended": {
@@ -287,9 +287,9 @@
"showAll": "全部显示", "showAll": "全部显示",
"subscriptions": "订阅", "subscriptions": "订阅",
"connectDevice": "连接设备", "connectDevice": "连接设备",
"devicesConnectedUnlimited": "已连接 {{count}} 台设备", "devicesConnectedUnlimited": "已连接 {{used}} 台设备 · 无限制",
"devicesOfMax": "{{used}} / {{max}}", "devicesOfMax": "{{used}} / {{max}}",
"maxUsage": "最大", "maxUsage": "最大 {{amount}}",
"remaining": "剩余", "remaining": "剩余",
"stats": { "stats": {
"balance": "余额", "balance": "余额",
@@ -298,9 +298,9 @@
"tariff": "套餐", "tariff": "套餐",
"trafficUsageTitle": "流量使用", "trafficUsageTitle": "流量使用",
"trialOffer": { "trialOffer": {
"freeDesc": "免费试用 {{days}} 天 — 无需付款", "freeDesc": "免费试用我们的 VPN无需任何承诺",
"freeTitle": "免费试用", "freeTitle": "免费试用",
"paidDesc": "{{days}} 天试用,仅需 {{price}}", "paidDesc": "从试用订阅开始吧",
"paidTitle": "付费试用" "paidTitle": "付费试用"
}, },
"unlimited": "无限", "unlimited": "无限",
@@ -458,7 +458,7 @@
"newDeviceLimit": "新设备限制", "newDeviceLimit": "新设备限制",
"reduce": "减少", "reduce": "减少",
"reduceDevices": "减少设备数量", "reduceDevices": "减少设备数量",
"reduceDevicesDescription": "调整您的设备限制。如有需要,请先断开多余设备的连接", "reduceDevicesDescription": "您可以将设备数量减少至套餐的最低限制",
"reduceDevicesTitle": "减少设备数量", "reduceDevicesTitle": "减少设备数量",
"reduceUnavailable": "当前无法减少设备数量", "reduceUnavailable": "当前无法减少设备数量",
"reducing": "正在减少…" "reducing": "正在减少…"
@@ -600,13 +600,40 @@
"purchaseButton": "⚡ 通过SBP自动扣款订阅", "purchaseButton": "⚡ 通过SBP自动扣款订阅",
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。" "purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
}, },
"lavaRecurring": {
"title": "Lava 自动续订",
"connect": "连接",
"payFirst": "支付首期账单",
"cancel": "关闭",
"confirmCancel": "要关闭 Lava 自动续订吗?订阅将不再自动续订。",
"cancelled": "已关闭 Lava 自动续订",
"enableError": "无法开启 Lava 自动续订",
"cancelError": "无法关闭 Lava 自动续订",
"statusPending": "等待支付首期账单",
"statusActive": "已启用",
"statusPastDue": "扣款失败 — 请重试",
"nextCharge": "下次扣款 {{date}}",
"amountPerPeriod": "{{amount}} ₽ {{period}}",
"amountPerDays": "每 {{days}} 天 {{amount}} ₽",
"period": {
"day": "每天",
"week": "每周",
"month": "每月",
"quarter": "每季度",
"halfYear": "每半年",
"year": "每年"
},
"autopayHint": "订阅将通过 Lava 自动续订",
"purchaseButton": "⚡ 使用 Lava 自动续订下单",
"purchaseHint": "首期账单通过 Lava 链接支付;之后按产品周期自动扣款续订。"
},
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"confirmDelete": "删除订阅?", "confirmDelete": "删除订阅?",
"cta": { "cta": {
"renewHint": "订阅已过期 — 续订以继续使用", "renewHint": "订阅已过期 — 续订以继续使用",
"activeHint": "订阅有效 — 您可以连接设备并使用 VPN", "activeHint": "续费和更换套餐",
"expiredHint": "订阅已过期 — 续订以继续使用", "expiredHint": "选择套餐并付款",
"trialHint": "您正在试用中 — 升级到付费套餐以解锁所有功能" "trialHint": "更多流量和设备"
}, },
"dailyAutoCharge": "每日自动扣费", "dailyAutoCharge": "每日自动扣费",
"defaultName": "订阅", "defaultName": "订阅",
@@ -626,16 +653,16 @@
"statusTrial": "试用", "statusTrial": "试用",
"daysShort": "天", "daysShort": "天",
"expiredBanner": { "expiredBanner": {
"selectTariff": "选择套餐", "selectTariff": "您的订阅已到期。请在下方选择套餐,以恢复 VPN 访问。",
"title": "订阅已过期" "title": "订阅已过期"
}, },
"trialInfo": { "trialInfo": {
"description": "免费试用我们的服务 {{days}} 天", "description": "您正在使用服务的试用版。请在下方选择套餐以获得完整访问权限。",
"remaining": "剩余 {{count}} 天", "remaining": "剩余",
"title": "试用期" "title": "试用期"
}, },
"trialUpgrade": { "trialUpgrade": {
"description": "升级到付费订阅以解锁所有功能并继续使用", "description": "您的试用期即将结束。请选择合适的套餐,以继续无限制使用 VPN。",
"title": "升级订阅" "title": "升级订阅"
} }
}, },
@@ -1012,7 +1039,7 @@
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。", "noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。",
"selectSubscription": "请先选择订阅" "selectSubscription": "请先选择订阅"
}, },
"payWithStars": "支付 {stars} Stars", "payWithStars": "支付 Stars",
"processingPayment": "处理中...", "processingPayment": "处理中...",
"starsPaymentSuccess": "支付成功抽奖结果已发送到Telegram。", "starsPaymentSuccess": "支付成功抽奖结果已发送到Telegram。",
"starsPaymentFailed": "支付失败。请重试。", "starsPaymentFailed": "支付失败。请重试。",
@@ -1667,7 +1694,7 @@
}, },
"broadcasts": { "broadcasts": {
"title": "广播", "title": "广播",
"subtitle": "历史和管理", "subtitle": "向用户群发消息",
"create": "创建群发", "create": "创建群发",
"empty": "没有广播", "empty": "没有广播",
"selectFilter": "选择受众", "selectFilter": "选择受众",
@@ -2023,7 +2050,15 @@
"offlineConv": "离线转化", "offlineConv": "离线转化",
"apiKey": "API 密钥", "apiKey": "API 密钥",
"legalFooter": "法律页脚", "legalFooter": "法律页脚",
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接" "legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接",
"botStartVideo": "机器人开始菜单视频",
"botStartVideoDesc": "机器人会在 /start 消息中附带该视频,替代 logo 图片。文件仅上传到 Telegram 一次,我们只保存其标识符。",
"botStartVideoUpload": "上传视频",
"botStartVideoReplace": "替换视频",
"botStartVideoRemove": "删除",
"botStartVideoActive": "已设置视频",
"botStartVideoNone": "未设置视频 — 菜单使用 logo 图片",
"botStartVideoError": "视频上传失败"
}, },
"buttons": { "buttons": {
"color": "按钮颜色", "color": "按钮颜色",
@@ -2072,7 +2107,7 @@
"builtinButtons": "内置栏目", "builtinButtons": "内置栏目",
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。", "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
"buttonTextPlaceholder": "按钮文本", "buttonTextPlaceholder": "按钮文本",
"customLabelsHint": "每种语言的按钮文本", "customLabelsHint": "留空 = 默认名称",
"moveUp": "上移", "moveUp": "上移",
"moveDown": "下移", "moveDown": "下移",
"openIn": "打开方式", "openIn": "打开方式",
@@ -2344,7 +2379,9 @@
"periodsRequired": "请添加至少一个期限", "periodsRequired": "请添加至少一个期限",
"dailyPriceRequired": "请输入大于0的每日价格", "dailyPriceRequired": "请输入大于0的每日价格",
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)" "trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
} },
"lavaProductLabel": "Lava 产品(自动续订)",
"lavaProductDesc": "来自 Lava 后台的产品 UUID价格与扣款周期在那里设置。留空则该套餐不支持自动续订。"
}, },
"servers": { "servers": {
"title": "服务器管理", "title": "服务器管理",
@@ -2624,10 +2661,10 @@
"programEnabled": "推荐计划已启用", "programEnabled": "推荐计划已启用",
"programEnabledDesc": "启用整个推荐人功能", "programEnabledDesc": "启用整个推荐人功能",
"requisitesText": "收款信息文本", "requisitesText": "收款信息文本",
"requisitesTextDesc": "将在合作申请表单中向用户显示", "requisitesTextDesc": "自定义提示,告知用户提现时需填写的收款信息",
"requisitesTextPlaceholder": "请输入您的收款信息...", "requisitesTextPlaceholder": "请输入您的收款信息...",
"withdrawalEnabled": "允许提款", "withdrawalEnabled": "允许提款",
"withdrawalEnabledDesc": "允许用户请求提款" "withdrawalEnabledDesc": "允许合作伙伴申请提现推荐余额"
}, },
"settingsLoadError": "加载推荐设置失败", "settingsLoadError": "加载推荐设置失败",
"settingsSection": { "settingsSection": {
@@ -3304,7 +3341,9 @@
"notificationsFailed": "(失败: {{count}}", "notificationsFailed": "(失败: {{count}}",
"sendError": "发送优惠失败", "sendError": "发送优惠失败",
"notificationsSent": "已发送通知", "notificationsSent": "已发送通知",
"offersCreated": "已创建优惠" "offersCreated": "已创建优惠",
"deliveryTitle": "Telegram 送达情况",
"openAsBroadcast": "作为群发查看"
}, },
"notFound": "未找到模板", "notFound": "未找到模板",
"noActiveTemplates": "没有活跃的模板", "noActiveTemplates": "没有活跃的模板",
@@ -3406,7 +3445,7 @@
"usersOnlineCount": "{{count}} 在线" "usersOnlineCount": "{{count}} 在线"
}, },
"overview": { "overview": {
"bandwidth": "实时带宽", "bandwidth": "各 inbound 流量",
"connections": "连接数", "connections": "连接数",
"cpu": "CPU核心", "cpu": "CPU核心",
"download": "下载", "download": "下载",
@@ -4939,40 +4978,48 @@
}, },
"activateButton": "激活", "activateButton": "激活",
"activateCodePlaceholder": "输入礼物代码", "activateCodePlaceholder": "输入礼物代码",
"activatedBy": "由 {{name}} 激活", "activatedBy": "由 {{username}} 激活",
"activateDescription": "输入您收到的礼物代码以激活订阅", "activateDescription": "输入您收到的礼物代码以激活订阅",
"activatedGiftsTitle": "已激活的礼物", "activatedGiftsTitle": "已激活的礼物",
"activateError": "激活礼物失败", "activateError": "激活礼物失败",
"activateSelfError": "您不能激活自己的礼物", "activateSelfError": "您不能激活自己的礼物",
"activateSuccess": "礼物已激活!", "activateSuccess": "礼物已激活!",
"activateSuccessDesc": "订阅添加到您的账户", "activateSuccessDesc": "{{tariff}}:已为您的订阅添加 {{days}} 天",
"activateTitle": "激活礼物", "activateTitle": "激活礼物",
"activeGiftsTitle": "活跃的礼物", "activeGiftsTitle": "活跃的礼物",
"codeLabel": "礼物代码", "codeLabel": "礼物代码",
"codeReadyTitle": "您的礼物代码已就绪", "codeReadyTitle": "您的礼物代码已就绪",
"copyMessage": "复制消息", "copyMessage": "复制消息",
"daysShort": "天", "daysShort": "天",
"deviceCount": "设备", "deviceCount": "{{count}} 台设备",
"devicesShort": "台", "devicesShort": "台",
"gbShort": "GB", "gbShort": "GB",
"myGiftsEmpty": "暂无礼物", "myGiftsEmpty": "暂无礼物",
"myGiftsEmptyDesc": "您发送或收到的礼物将显示在这里", "myGiftsEmptyDesc": "为好友购买礼物,或激活已收到的兑换码",
"pageTitle": "礼物", "pageTitle": "礼物",
"receivedGiftsTitle": "已收到的礼物", "receivedGiftsTitle": "已收到的礼物",
"selectPeriod": "选择时长", "selectPeriod": "选择时长",
"selectTariff": "选择套餐", "selectTariff": "选择套餐",
"sentTo": "已发送给 {{name}}", "sentTo": "已发送给 {{recipient}}",
"shareGift": "分享礼物", "shareGift": "分享礼物",
"shareModalActivateVia": "通过以下方式激活", "shareModalActivateVia": "通过机器人激活",
"shareModalActivateViaCabinet": "通过个人中心", "shareModalActivateViaCabinet": "通过个人中心",
"shareText": "我为您准备了一份 Bedolaga VPN 礼物{{code}}", "shareText": "我给你准备了一份 Bedolaga VPN 礼物,在这里激活它:",
"shareToastCopied": "链接已复制", "shareToastCopied": "链接已复制",
"statusActivated": "已激活", "statusActivated": "已激活",
"statusAvailable": "可激活", "statusAvailable": "可激活",
"tabActivate": "激活", "tabActivate": "激活",
"tabBuy": "购买", "tabBuy": "购买",
"tabMyGifts": "我的礼物", "tabMyGifts": "我的礼物",
"unlimitedTraffic": "无限流量" "unlimitedTraffic": "无限流量",
"qrHint": "扫描以激活礼物",
"scanButton": "📷 扫描二维码",
"scanInProgress": "扫描中…",
"scanCancel": "停止扫描",
"scanDescription": "将摄像头对准礼物二维码",
"scanNotRecognized": "该二维码中没有礼物码",
"scanError": "扫描失败",
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
}, },
"news": { "news": {
"title": "新闻与更新", "title": "新闻与更新",

View File

@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { StatCard } from '@/components/stats';
import { import {
BanIcon, BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
import {
DocumentIcon, DocumentIcon,
EmailIcon, EmailIcon,
PhotoIcon, PhotoIcon,
RefreshIcon, RefreshIcon,
SendIcon,
StopIcon, StopIcon,
TelegramIcon, TelegramIcon,
UsersIcon,
VideoIcon, VideoIcon,
XCircleIcon,
} from '@/components/icons'; } from '@/components/icons';
// Channel badge component // Channel badge component
@@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
); );
} }
// Status badge component
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export default function AdminBroadcastDetail() { export default function AdminBroadcastDetail() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
return adminBroadcastsApi.get(broadcastId); return adminBroadcastsApi.get(broadcastId);
}, },
enabled: !!broadcastId && !isNaN(broadcastId), enabled: !!broadcastId && !isNaN(broadcastId),
refetchInterval: (query) => { refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
const data = query.state.data;
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
return 3000;
}
return false;
},
}); });
// Stop mutation // Stop mutation
@@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() {
}, },
}); });
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status); const isRunning = broadcast && isBroadcastInFlight(broadcast.status);
if (!broadcastId || isNaN(broadcastId)) { if (!broadcastId || isNaN(broadcastId)) {
navigate('/admin/broadcasts'); navigate('/admin/broadcasts');
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
<h1 className="text-xl font-bold text-dark-100"> <h1 className="text-xl font-bold text-dark-100">
{t('admin.broadcasts.detail')} #{broadcast.id} {t('admin.broadcasts.detail')} #{broadcast.id}
</h1> </h1>
<StatusBadge status={broadcast.status} /> <BroadcastStatusBadge status={broadcast.status} />
<ChannelBadge channel={broadcast.channel} /> <ChannelBadge channel={broadcast.channel} />
</div> </div>
<p className="text-sm text-dark-400"> <p className="text-sm text-dark-400">
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
</button> </button>
</div> </div>
{/* Progress */} {/* Progress + stats */}
{isRunning && ( <BroadcastDeliveryStats
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4"> status={broadcast.status}
<div className="mb-2 flex justify-between text-sm"> progressPercent={broadcast.progress_percent}
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span> totalCount={broadcast.total_count}
<span className="font-medium text-dark-100"> sentCount={broadcast.sent_count}
{broadcast.progress_percent.toFixed(1)}% blockedCount={broadcast.blocked_count}
</span> failedCount={broadcast.failed_count}
</div> />
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={broadcast.total_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={broadcast.sent_count}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={broadcast.blocked_count}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={broadcast.failed_count}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
{/* Target */} {/* Target */}
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createNumberInputHandler } from '../utils/inputHelpers'; import { createNumberInputHandler } from '../utils/inputHelpers';
import { couponsApi, CouponBatchCreated } from '../api/coupons'; import { couponsApi, type CouponBatchCreated } from '../api/coupons';
import { tariffsApi } from '../api/tariffs'; import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard'; import { copyToClipboard } from '../utils/clipboard';
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
const [couponsCount, setCouponsCount] = useState<number | ''>(50); const [couponsCount, setCouponsCount] = useState<number | ''>(50);
const [priceRubles, setPriceRubles] = useState<number | ''>(''); const [priceRubles, setPriceRubles] = useState<number | ''>('');
const [validDays, setValidDays] = useState<number | ''>(''); const [validDays, setValidDays] = useState<number | ''>('');
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
const [validationErrors, setValidationErrors] = useState<string[]>([]); const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
coupons_count: Number(couponsCount), coupons_count: Number(couponsCount),
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100), wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
valid_days: validDays === '' ? 0 : Number(validDays), valid_days: validDays === '' ? 0 : Number(validDays),
max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser),
}); });
}; };
@@ -275,6 +277,21 @@ export default function AdminCouponCreate() {
/> />
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-dark-300">
{t('admin.coupons.form.maxPerUser')}
</label>
<input
type="number"
value={maxPerUser}
onChange={createNumberInputHandler(setMaxPerUser, 0, 500)}
min={0}
max={500}
placeholder="0"
className="input w-full"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.maxPerUserHint')}</p>
</div>
</div> </div>
{/* Actions */} {/* Actions */}

View File

@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
const batchId = Number(id); const batchId = Number(id);
const [revokeConfirm, setRevokeConfirm] = useState(false); const [revokeConfirm, setRevokeConfirm] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const { data: batch, isLoading } = useQuery({ const { data: batch, isLoading } = useQuery({
@@ -70,6 +71,28 @@ export default function AdminCouponDetail() {
}, },
}); });
const deleteMutation = useMutation({
mutationFn: () => couponsApi.deleteBatch(batchId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
setDeleteConfirm(false);
showToast({
type: 'success',
title: t('admin.coupons.delete.success', { count: result.deleted_coupons }),
message: '',
});
navigate('/admin/coupons', { replace: true });
},
onError: (err) => {
setDeleteConfirm(false);
showToast({
type: 'error',
title: t('admin.coupons.errors.deleteFailed'),
message: getApiErrorMessage(err, ''),
});
},
});
const handleCopyAll = () => { const handleCopyAll = () => {
if (!links) return; if (!links) return;
void copyToClipboard(links.links.join('\n')); void copyToClipboard(links.links.join('\n'));
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
</span> </span>
</div> </div>
)} )}
{batch.max_per_user > 0 && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.maxPerUser')}</span>
<span className="text-dark-100">{batch.max_per_user}</span>
</div>
)}
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span> <span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100"> <span className="text-dark-100">
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
</PermissionGate> </PermissionGate>
)} )}
{/* Delete — полностью убирает партию, в отличие от отзыва */}
<PermissionGate permission="coupons:edit" fallback={null}>
<button
onClick={() => setDeleteConfirm(true)}
className="w-full rounded-lg border border-error-500/30 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
>
{t('admin.coupons.delete.button')}
</button>
</PermissionGate>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
<h3 className="mb-2 text-lg font-semibold text-dark-100">
{t('admin.coupons.delete.confirmTitle')}
</h3>
<p className="mb-2 text-dark-400">
{t('admin.coupons.delete.confirmText', { count: batch.coupons_total })}
</p>
{batch.redeemed_count > 0 && (
<p className="mb-4 text-sm text-warning-400">
{t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })}
</p>
)}
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(false)} className="btn-secondary flex-1">
{t('admin.coupons.revoke.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
deleteMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
}`}
>
{t('admin.coupons.delete.confirm')}
</button>
</div>
</div>
</div>
)}
{/* Revoke confirmation */} {/* Revoke confirmation */}
{revokeConfirm && ( {revokeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">

View File

@@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
promoOffersApi, promoOffersApi,
PromoOfferBroadcastRequest, type PromoOfferBroadcastRequest,
TARGET_SEGMENTS, TARGET_SEGMENTS,
TargetSegment, type TargetSegment,
OFFER_TYPE_CONFIG, OFFER_TYPE_CONFIG,
OfferType, type OfferType,
} from '../api/promoOffers'; } from '../api/promoOffers';
import { adminUsersApi, UserListItem } from '../api/adminUsers'; import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { adminUsersApi, type UserListItem } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import {
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval } from '../utils/broadcastStatus';
import { import {
SendIcon, SendIcon,
CheckIcon, CheckIcon,
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
title: string; title: string;
message: string; message: string;
isSuccess: boolean; isSuccess: boolean;
broadcastId?: number | null;
} | null>(null); } | null>(null);
// Query templates // Query templates
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
queryFn: promoOffersApi.getTemplates, queryFn: promoOffersApi.getTemplates,
}); });
// Recipient counts per segment — админ видит охват до отправки
const { data: segmentsData } = useQuery({
queryKey: ['admin-promo-segments'],
queryFn: promoOffersApi.getSegments,
staleTime: 60000,
});
const segmentCounts = new Map(
(segmentsData?.segments || []).map((segment) => [segment.key, segment.count]),
);
const selectedSegmentCount = segmentCounts.get(selectedTarget);
// Delivery progress of the offer we have just sent
const broadcastId = result?.broadcastId ?? null;
const { data: delivery } = useQuery({
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
queryFn: async () => adminBroadcastsApi.get(broadcastId as number),
enabled: broadcastId !== null,
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
});
const templates = templatesData?.items || []; const templates = templatesData?.items || [];
const activeTemplates = templates.filter((t) => t.is_active); const activeTemplates = templates.filter((t) => t.is_active);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
mutationFn: promoOffersApi.broadcastOffer, mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] }); queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers }); let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
if (data.notifications_sent > 0 || data.notifications_failed > 0) { if (data.notifications_sent > 0 || data.notifications_failed > 0) {
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
title: t('admin.promoOffers.result.sentTitle'), title: t('admin.promoOffers.result.sentTitle'),
message, message,
isSuccess: true, isSuccess: true,
broadcastId: data.broadcast_id,
}); });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
if (result) { if (result) {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-2xl py-12 text-center">
<div <div
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${ className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20' result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
</div> </div>
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3> <h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
<p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p> <p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p>
{/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */}
{delivery && (
<div className="mb-6 space-y-4 text-left">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-dark-300">
{t('admin.promoOffers.result.deliveryTitle')}
</span>
<BroadcastStatusBadge status={delivery.status} />
</div>
<BroadcastDeliveryStats
status={delivery.status}
progressPercent={delivery.progress_percent}
totalCount={delivery.total_count}
sentCount={delivery.sent_count}
blockedCount={delivery.blocked_count}
failedCount={delivery.failed_count}
/>
<button
onClick={() => navigate(`/admin/broadcasts/${delivery.id}`)}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.promoOffers.result.openAsBroadcast')}
</button>
</div>
)}
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
<button <button
onClick={() => navigate('/admin/promo-offers')} onClick={() => navigate('/admin/promo-offers')}
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
</div> </div>
{sendMode === 'segment' ? ( {sendMode === 'segment' ? (
<select <>
value={selectedTarget} <select
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)} value={selectedTarget}
className="input" onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
> className="input"
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => ( >
<option key={key} value={key}> {Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
{t(labelKey)} const count = segmentCounts.get(key);
</option> return (
))} <option key={key} value={key}>
</select> {count === undefined
? t(labelKey)
: `${t(labelKey)}${count} ${t('admin.broadcasts.recipients')}`}
</option>
);
})}
</select>
{selectedSegmentCount !== undefined && (
<div className="mt-2 text-sm text-dark-400">
{t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{selectedSegmentCount}</strong>
</div>
)}
</>
) : ( ) : (
<div ref={searchRef} className="relative"> <div ref={searchRef} className="relative">
{selectedUser ? ( {selectedUser ? (

View File

@@ -50,6 +50,7 @@ export default function AdminTariffCreate() {
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null); const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]); const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0); const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
const [lavaProductId, setLavaProductId] = useState('');
// Traffic topup // Traffic topup
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false); const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
@@ -122,6 +123,7 @@ export default function AdminTariffCreate() {
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [], data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
); );
setDailyPriceKopeks(data.daily_price_kopeks || 0); setDailyPriceKopeks(data.daily_price_kopeks || 0);
setLavaProductId(data.lava_product_id || '');
setTrafficTopupEnabled(data.traffic_topup_enabled || false); setTrafficTopupEnabled(data.traffic_topup_enabled || false);
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0); setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
setTrafficTopupPackages(data.traffic_topup_packages || {}); setTrafficTopupPackages(data.traffic_topup_packages || {});
@@ -176,6 +178,8 @@ export default function AdminTariffCreate() {
max_topup_traffic_gb: toNumber(maxTopupTrafficGb), max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
is_daily: isDaily, is_daily: isDaily,
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0, daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
// Пустая строка отвязывает тариф от продукта Lava
lava_product_id: lavaProductId.trim(),
traffic_reset_mode: trafficResetMode, traffic_reset_mode: trafficResetMode,
}; };
@@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
</div> </div>
)} )}
{/* Lava recurrent product */}
<div>
<label
htmlFor="tariff-lava-product"
className="mb-2 block text-sm font-medium text-dark-300"
>
{t('admin.tariffs.lavaProductLabel')}
</label>
<input
id="tariff-lava-product"
type="text"
value={lavaProductId}
onChange={(e) => setLavaProductId(e.target.value)}
className="input w-full"
placeholder="6be21df9-0bcd-44ac-9c2c-3be7bc94decc"
/>
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.lavaProductDesc')}</p>
</div>
{/* Traffic Limit */} {/* Traffic Limit */}
<div> <div>
<label <label

View File

@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { giftApi } from '../api/gift'; import { giftApi } from '../api/gift';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { Spinner } from '@/components/ui/Spinner'; import { Spinner } from '@/components/ui/Spinner';
@@ -121,6 +122,14 @@ function CodeOnlySuccessState({
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p> <p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
</div> </div>
{/* QR — получателю проще отсканировать, чем копировать ссылку.
Кодируем bot-ссылку, если она есть: активация в боте — основной путь,
иначе ссылку кабинета. */}
<div className="flex w-full flex-col items-center gap-2 rounded-xl border border-dark-700/30 bg-white p-4">
<QRCodeSVG value={botLink ?? cabinetLink} size={180} level="M" includeMargin={false} />
</div>
<p className="-mt-3 text-xs text-dark-400">{t('gift.qrHint', 'Scan to activate the gift')}</p>
{/* Share message preview */} {/* Share message preview */}
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left"> <div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
<p className="mb-3 text-sm font-medium text-dark-100"> <p className="mb-3 text-sm font-medium text-dark-100">

View File

@@ -1,11 +1,18 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState, useMemo, useEffect, useCallback } from 'react'; import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useNavigate, useSearchParams, Link } from 'react-router'; import { useNavigate, useSearchParams, Link } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { giftApi } from '../api/gift'; import { giftApi } from '../api/gift';
import {
canUseTelegramScanner,
loadHtml5Qrcode,
parseGiftCode,
scanWithTelegram,
type Html5QrcodeInstance,
} from '@/utils/qrScanner';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import type { import type {
GiftConfig, GiftConfig,
@@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
if (initialCode) setCode(initialCode); if (initialCode) setCode(initialCode);
}, [initialCode]); }, [initialCode]);
const [activateError, setActivateError] = useState<string | null>(null); const [activateError, setActivateError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const scannerRef = useRef<Html5QrcodeInstance | null>(null);
const stopScan = useCallback(() => {
if (scannerRef.current) {
scannerRef.current
.stop()
.catch(() => undefined)
.finally(() => {
scannerRef.current?.clear();
scannerRef.current = null;
});
}
setScanning(false);
}, []);
// Веб-сканер держит камеру: гасим его при уходе с вкладки/размонтировании,
// иначе индикатор камеры остаётся гореть.
useEffect(() => stopScan, [stopScan]);
const applyScannedCode = useCallback(
(decoded: string) => {
const parsed = parseGiftCode(decoded);
if (!parsed) {
setActivateError(t('gift.scanNotRecognized'));
return;
}
setCode(parsed);
setActivateError(null);
},
[t],
);
const handleScan = useCallback(async () => {
setActivateError(null);
if (canUseTelegramScanner()) {
try {
const decoded = await scanWithTelegram(
t('gift.scanDescription'),
(v) => parseGiftCode(v) !== null,
);
if (decoded) applyScannedCode(decoded);
} catch {
setActivateError(t('gift.scanError'));
}
return;
}
const Html5Qrcode = await loadHtml5Qrcode();
if (!Html5Qrcode) {
setActivateError(t('gift.scanNoCamera'));
return;
}
setScanning(true);
const scanner = new Html5Qrcode('gift-qr-reader');
scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
const onDecoded = (decoded: string) => {
if (parseGiftCode(decoded) === null) return;
stopScan();
applyScannedCode(decoded);
};
try {
await scanner.start({ facingMode: 'environment' }, config, onDecoded, () => undefined);
} catch {
try {
await scanner.start({ facingMode: 'user' }, config, onDecoded, () => undefined);
} catch {
setActivateError(t('gift.scanNoCamera'));
scannerRef.current = null;
setScanning(false);
}
}
}, [applyScannedCode, stopScan, t]);
const activateMutation = useMutation({ const activateMutation = useMutation({
mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode), mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode),
@@ -810,6 +893,32 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
aria-label={t('gift.activateTitle')} aria-label={t('gift.activateTitle')}
/> />
{/* Скан QR: в Telegram — нативный сканер (в WebView камера через
getUserMedia работает ненадёжно), в вебе — html5-qrcode. */}
<button
type="button"
onClick={handleScan}
disabled={scanning}
className="mt-3 w-full rounded-2xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50 disabled:opacity-50"
>
{scanning ? t('gift.scanInProgress') : t('gift.scanButton')}
</button>
{/* Контейнер веб-сканера: html5-qrcode рендерит превью камеры внутрь */}
<div
id="gift-qr-reader"
className={cn('mt-3 overflow-hidden rounded-2xl', !scanning && 'hidden')}
/>
{scanning && (
<button
type="button"
onClick={stopScan}
className="mt-2 w-full rounded-2xl border border-dark-700/50 px-6 py-2 text-xs text-dark-400 transition-colors hover:bg-dark-800/50"
>
{t('gift.scanCancel')}
</button>
)}
</div> </div>
{/* Error */} {/* Error */}

View File

@@ -5,6 +5,7 @@ import { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription'; import { subscriptionApi } from '../api/subscription';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme'; import { getGlassColors } from '../utils/glassTheme';
import { getMonthlyPriceKopeks } from '../utils/pricing';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '../platform'; import { useHaptic } from '../platform';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
@@ -143,8 +144,7 @@ export default function RenewSubscription() {
{options.map((option) => { {options.map((option) => {
const isSelected = selectedPeriod === option.period_days; const isSelected = selectedPeriod === option.period_days;
const canAfford = balanceKopeks >= option.price_kopeks; const canAfford = balanceKopeks >= option.price_kopeks;
const months = Math.max(1, Math.round(option.period_days / 30)); const perMonth = getMonthlyPriceKopeks(option.price_kopeks, option.period_days);
const perMonth = option.price_kopeks / months;
return ( return (
<button <button
@@ -167,7 +167,7 @@ export default function RenewSubscription() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="text-base font-semibold" style={{ color: g.text }}> <span className="text-base font-semibold" style={{ color: g.text }}>
{option.period_days} {t('common.units.days', 'дней')} {option.period_days} {t('subscription.days', 'дней')}
</span> </span>
{option.discount_percent > 0 && ( {option.discount_percent > 0 && (
<span className="ml-2 rounded-full bg-success-400/15 px-2 py-0.5 text-[10px] font-semibold text-success-400"> <span className="ml-2 rounded-full bg-success-400/15 px-2 py-0.5 text-[10px] font-semibold text-success-400">
@@ -181,10 +181,10 @@ export default function RenewSubscription() {
? t('subscription.free', 'Бесплатно') ? t('subscription.free', 'Бесплатно')
: `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`} : `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`}
</div> </div>
{months > 1 && ( {perMonth !== null && (
<div className="text-[11px]" style={{ color: g.textSecondary }}> <div className="text-[11px]" style={{ color: g.textSecondary }}>
{formatAmount(perMonth / 100)} {currencySymbol}/ {formatAmount(perMonth / 100)} {currencySymbol}/
{t('common.units.mo', 'мес')} {t('subscription.month', 'мес')}
</div> </div>
)} )}
{option.original_price_kopeks && ( {option.original_price_kopeks && (

View File

@@ -43,6 +43,12 @@ import {
sbpUiState, sbpUiState,
type SbpUiState, type SbpUiState,
} from '../utils/sbpRecurring'; } from '../utils/sbpRecurring';
import {
isLavaFeatureDisabledError,
lavaPeriodLabelKey,
lavaUiState,
type LavaUiState,
} from '../utils/lavaRecurring';
import Twemoji from 'react-twemoji'; import Twemoji from 'react-twemoji';
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet'; import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet'; import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
@@ -375,6 +381,78 @@ export default function Subscription() {
cancelSbpMutation.mutate(); cancelSbpMutation.mutate();
}; };
// Автопродление Lava — независимый от Platega движок с той же семантикой
// состояний. Поллинг раз в 8с, пока привязка PENDING (ждём оплату первого
// счёта), чтобы UI сам перешёл в active/past_due.
const lavaQuery = useQuery({
queryKey: ['lava-recurring', subscriptionId],
queryFn: () => subscriptionApi.getLavaRecurring(subscriptionId),
enabled: !!subscription && !subscription.is_trial,
retry: false,
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
});
const lavaInfo = lavaQuery.data;
const lavaFeatureDisabled = isLavaFeatureDisabledError(lavaQuery.error);
const lavaUiStateValue: LavaUiState =
lavaInfo !== undefined || lavaFeatureDisabled
? lavaUiState(lavaInfo, lavaFeatureDisabled)
: 'hidden';
const enableLavaMutation = useMutation({
mutationFn: () => subscriptionApi.enableLavaRecurring(subscriptionId),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
// Бэкенд снимает autopay_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.lavaRecurring.enableError'),
message: '',
duration: 3000,
});
},
});
const cancelLavaMutation = useMutation({
mutationFn: () => subscriptionApi.cancelLavaRecurring(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
showToast({
type: 'success',
title: t('subscription.lavaRecurring.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.lavaRecurring.cancelError'),
message: '',
duration: 3000,
});
},
});
const handleCancelLava = async () => {
const confirmed = await destructiveConfirm(
t('subscription.lavaRecurring.confirmCancel'),
t('subscription.lavaRecurring.cancel'),
);
if (!confirmed) return;
cancelLavaMutation.mutate();
};
const autopayMutation = useMutation({ const autopayMutation = useMutation({
mutationFn: (enabled: boolean) => mutationFn: (enabled: boolean) =>
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
@@ -1280,6 +1358,128 @@ export default function Subscription() {
</div> </div>
</div> </div>
)} )}
{/* ─── Автопродление Lava ───
Независимый от Platega движок: сиблинг того же тоггла, те же
состояния. Период задан продуктом в кабинете Lava и приезжает
числом дней, поэтому подпись строится из charge_days. */}
{!subscription.is_trial && lavaUiStateValue !== 'hidden' && (
<div
className="mt-3 rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<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.lavaRecurring.title')}
</div>
{lavaUiStateValue === 'off' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.autopayHint')}
</div>
)}
{lavaUiStateValue === 'pending' && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.statusPending')}
</div>
)}
{lavaUiStateValue === 'active' && lavaInfo && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{(() => {
const periodKey = lavaPeriodLabelKey(lavaInfo.charge_days);
const amount = formatAmount((lavaInfo.amount_kopeks ?? 0) / 100);
return periodKey
? t('subscription.lavaRecurring.amountPerPeriod', {
amount,
period: t(periodKey),
})
: t('subscription.lavaRecurring.amountPerDays', {
amount,
days: lavaInfo.charge_days ?? 0,
});
})()}
</div>
{lavaInfo.next_charge_at && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.lavaRecurring.nextCharge', {
date: new Date(lavaInfo.next_charge_at).toLocaleDateString(
uiLocale(),
{
day: '2-digit',
month: '2-digit',
year: 'numeric',
},
),
})}
</div>
)}
</>
)}
{lavaUiStateValue === 'past_due' && (
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
{t('subscription.lavaRecurring.statusPastDue')}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:items-end">
{lavaUiStateValue === 'off' && (
<button
onClick={() => enableLavaMutation.mutate()}
disabled={enableLavaMutation.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"
>
{enableLavaMutation.isPending ? (
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
t('subscription.lavaRecurring.connect')
)}
</button>
)}
{lavaUiStateValue === 'pending' && (
<>
{lavaInfo?.redirect_url && (
<button
onClick={() => {
if (lavaInfo.redirect_url) {
openPaymentUrl(lavaInfo.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.lavaRecurring.payFirst')}
</button>
)}
<button
onClick={handleCancelLava}
disabled={cancelLavaMutation.isPending}
className="text-[11px] font-medium transition-colors disabled:opacity-50 sm:text-right"
style={{ color: 'rgb(var(--color-critical-500))' }}
>
{t('subscription.lavaRecurring.cancel')}
</button>
</>
)}
{(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && (
<button
onClick={handleCancelLava}
disabled={cancelLavaMutation.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.lavaRecurring.cancel')}
</button>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
})() })()

View File

@@ -292,6 +292,12 @@ export default function SubscriptionPurchase() {
'platega_recurrent_enabled' in purchaseOptions && 'platega_recurrent_enabled' in purchaseOptions &&
purchaseOptions.platega_recurrent_enabled === true purchaseOptions.platega_recurrent_enabled === true
} }
lavaPurchaseEnabled={
isTariffsMode &&
purchaseOptions !== undefined &&
'lava_recurrent_enabled' in purchaseOptions &&
purchaseOptions.lava_recurrent_enabled === true
}
onBack={() => { onBack={() => {
setShowTariffPurchase(false); setShowTariffPurchase(false);
setSelectedTariff(null); setSelectedTariff(null);

View File

@@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions {
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с // СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
// автооплатой СБП» рядом с покупкой с баланса // автооплатой СБП» рядом с покупкой с баланса
platega_recurrent_enabled?: boolean; platega_recurrent_enabled?: boolean;
lava_recurrent_enabled?: boolean;
} }
export interface ClassicPurchaseOptions { export interface ClassicPurchaseOptions {
@@ -661,6 +662,18 @@ export interface SbpRecurringInfo {
redirect_url?: string | null; redirect_url?: string | null;
} }
/**
* Автопродление Lava. В отличие от Platega период задан продуктом в кабинете
* Lava и приезжает числом дней (charge_days), а не enum-интервалом.
*/
export interface LavaRecurringInfo {
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
charge_days?: number;
amount_kopeks?: number;
next_charge_at?: string | null;
redirect_url?: string | null;
}
// Ticket notifications types // Ticket notifications types
export interface TicketNotification { export interface TicketNotification {
id: number; id: number;

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus';
describe('broadcastStatus', () => {
it('treats queued, in-progress and cancelling deliveries as running', () => {
expect(isBroadcastInFlight('queued')).toBe(true);
expect(isBroadcastInFlight('in_progress')).toBe(true);
expect(isBroadcastInFlight('cancelling')).toBe(true);
});
it('treats finished deliveries as done', () => {
expect(isBroadcastInFlight('completed')).toBe(false);
expect(isBroadcastInFlight('partial')).toBe(false);
expect(isBroadcastInFlight('failed')).toBe(false);
expect(isBroadcastInFlight('cancelled')).toBe(false);
});
it('handles a missing status without polling', () => {
expect(isBroadcastInFlight(undefined)).toBe(false);
expect(broadcastPollInterval(undefined)).toBe(false);
});
it('polls only while the delivery is running', () => {
expect(broadcastPollInterval('in_progress')).toBe(3000);
expect(broadcastPollInterval('completed')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */
export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const;
/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */
export function isBroadcastInFlight(status: string | undefined): boolean {
if (!status) return false;
return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status);
}
/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */
export function broadcastPollInterval(status: string | undefined): number | false {
return isBroadcastInFlight(status) ? 3000 : false;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { isLavaFeatureDisabledError, lavaPeriodLabelKey, lavaUiState } from './lavaRecurring';
describe('isLavaFeatureDisabledError', () => {
it('detects exactly the disabled-feature 403', () => {
expect(
isLavaFeatureDisabledError({
response: { status: 403, data: { detail: 'Lava recurrent disabled' } },
}),
).toBe(true);
});
it('ignores other 403s and malformed errors', () => {
// Другие guard'ы отдают тот же статус с иным detail — их прятать нельзя
expect(
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: 'Blacklisted' } } }),
).toBe(false);
expect(
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: { code: 'x' } } } }),
).toBe(false);
expect(isLavaFeatureDisabledError({ response: { status: 500, data: {} } })).toBe(false);
expect(isLavaFeatureDisabledError({ response: { status: 403 } })).toBe(false);
expect(isLavaFeatureDisabledError(null)).toBe(false);
expect(isLavaFeatureDisabledError('boom')).toBe(false);
});
});
describe('lavaUiState', () => {
it('hides the block when the feature is disabled', () => {
expect(lavaUiState({ status: 'ACTIVE' }, true)).toBe('hidden');
});
it('maps backend statuses to UI states', () => {
expect(lavaUiState({ status: 'PENDING' }, false)).toBe('pending');
expect(lavaUiState({ status: 'ACTIVE' }, false)).toBe('active');
expect(lavaUiState({ status: 'PAST_DUE' }, false)).toBe('past_due');
expect(lavaUiState({ status: 'none' }, false)).toBe('off');
});
it('degrades unknown or missing state to off instead of hanging', () => {
expect(lavaUiState({ status: 'SOMETHING_NEW' }, false)).toBe('off');
expect(lavaUiState(undefined, false)).toBe('off');
});
});
describe('lavaPeriodLabelKey', () => {
it('labels canonical periods', () => {
expect(lavaPeriodLabelKey(30)).toBe('subscription.lavaRecurring.period.month');
expect(lavaPeriodLabelKey(365)).toBe('subscription.lavaRecurring.period.year');
});
it('returns null for non-canonical periods so the caller falls back to "N days"', () => {
expect(lavaPeriodLabelKey(45)).toBeNull();
expect(lavaPeriodLabelKey(undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,77 @@
import type { LavaRecurringInfo } from '../types';
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
/**
* true, если ошибка — это ровно "автопродление Lava выключено на бэке"
* (403 с detail==='Lava recurrent disabled'). Сравнение строгое: тот же
* 403-статус используют другие guard'ы с detail-объектом, и на любой форме
* detail (строка/объект/отсутствует) функция не должна падать.
*/
export function isLavaFeatureDisabledError(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 === 'Lava recurrent disabled';
}
export type LavaUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
/**
* Сводит статус автопродления Lava к состоянию UI. Бэк отдаёт из GET только
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED терминальны и
* не возвращаются), но любой нераспознанный статус деградирует в 'off', а не
* подвешивает интерфейс.
*/
export function lavaUiState(
info: LavaRecurringInfo | undefined,
featureDisabled: boolean,
): LavaUiState {
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';
}
}
/**
* Локализационный ключ периодичности списания. У Lava период задан продуктом
* в кабинете провайдера и приезжает числом дней, поэтому точные подписи есть
* только для канонических значений; остальное показывается как "N дней".
*/
export function lavaPeriodLabelKey(chargeDays: number | undefined): string | null {
switch (chargeDays) {
case 1:
return 'subscription.lavaRecurring.period.day';
case 7:
return 'subscription.lavaRecurring.period.week';
case 30:
return 'subscription.lavaRecurring.period.month';
case 90:
return 'subscription.lavaRecurring.period.quarter';
case 180:
return 'subscription.lavaRecurring.period.halfYear';
case 365:
return 'subscription.lavaRecurring.period.year';
default:
return null;
}
}

26
src/utils/pricing.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getMonthlyPriceKopeks } from './pricing';
describe('getMonthlyPriceKopeks', () => {
it('hides the monthly rate for periods of a month or shorter', () => {
expect(getMonthlyPriceKopeks(4830, 7)).toBeNull();
expect(getMonthlyPriceKopeks(9900, 14)).toBeNull();
expect(getMonthlyPriceKopeks(16030, 30)).toBeNull();
});
it('divides by whole months for multiples of 30 days', () => {
expect(getMonthlyPriceKopeks(30000, 90)).toBe(10000);
expect(getMonthlyPriceKopeks(60000, 180)).toBe(10000);
});
it('prorates periods that are not whole months', () => {
expect(getMonthlyPriceKopeks(15000, 45)).toBe(10000);
expect(getMonthlyPriceKopeks(100000, 365)).toBe(8219);
});
it('returns null for non-finite input', () => {
expect(getMonthlyPriceKopeks(Number.NaN, 90)).toBeNull();
expect(getMonthlyPriceKopeks(30000, Number.NaN)).toBeNull();
expect(getMonthlyPriceKopeks(30000, Number.POSITIVE_INFINITY)).toBeNull();
});
});

15
src/utils/pricing.ts Normal file
View File

@@ -0,0 +1,15 @@
/** Длина «месяца» в днях — тот же множитель, что использует бэкенд при расчёте цены за месяц. */
const DAYS_IN_MONTH = 30;
/**
* Цена за месяц (в копейках) для периода длиной `days` дней.
*
* Возвращает `null`, когда месячная ставка не имеет смысла: для периода
* в месяц и короче она либо повторяет цену периода (30 дней), либо
* выдаёт цену периода за месячную (7 дней → цена семи дней «за месяц»).
*/
export function getMonthlyPriceKopeks(priceKopeks: number, days: number): number | null {
if (!Number.isFinite(priceKopeks) || !Number.isFinite(days)) return null;
if (days <= DAYS_IN_MONTH) return null;
return Math.round((priceKopeks * DAYS_IN_MONTH) / days);
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { parseGiftCode } from './qrScanner';
describe('parseGiftCode', () => {
it('reads the bot deep link (main distribution path)', () => {
expect(parseGiftCode('https://t.me/mybot?start=GIFT_abc123def456')).toBe('abc123def456');
});
it('reads the cabinet activation link', () => {
expect(parseGiftCode('https://cab.example.com/gift?tab=activate&code=abc123def456')).toBe(
'abc123def456',
);
});
it('reads a bare code with and without the GIFT prefix', () => {
expect(parseGiftCode('GIFT-abc123def456')).toBe('abc123def456');
expect(parseGiftCode('abc123def456')).toBe('abc123def456');
expect(parseGiftCode(' abc123def456 ')).toBe('abc123def456');
});
it('rejects unrelated QR content instead of filling the field with junk', () => {
// Иначе сканер «распознал» бы любую ссылку и подставил мусор в поле кода
expect(parseGiftCode('https://example.com/some/page')).toBeNull();
expect(parseGiftCode('WIFI:S:MyNet;T:WPA;P:secret;;')).toBeNull();
expect(parseGiftCode('short')).toBeNull();
expect(parseGiftCode('')).toBeNull();
expect(parseGiftCode(null)).toBeNull();
expect(parseGiftCode(undefined)).toBeNull();
});
});

106
src/utils/qrScanner.ts Normal file
View File

@@ -0,0 +1,106 @@
import {
isQrScannerSupported,
openQrScanner,
retrieveLaunchParams,
} from '@telegram-apps/sdk-react';
// Сканирование QR внутри Telegram: WebView не даёт нормального доступа к камере,
// поэтому на мобильных клиентах используется НАТИВНЫЙ сканер Telegram, а на вебе
// — html5-qrcode с CDN. Логика вынесена из TvQuickConnect, чтобы её мог
// переиспользовать любой экран (подарки и т.д.).
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js).
// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами
// страницы. При несовпадении браузер откажется исполнять скрипт — сканер
// деградирует в «камера недоступна», а не запускает чужой код.
const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+';
export interface Html5QrcodeInstance {
start: (
camera: { facingMode: string },
config: { fps: number; qrbox: { width: number; height: number } },
onSuccess: (decoded: string) => void,
onError: () => void,
) => Promise<void>;
stop: () => Promise<void>;
clear: () => void;
}
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
export function isTelegramMobile(): boolean {
try {
return TG_MOBILE_PLATFORMS.has(retrieveLaunchParams().tgWebAppPlatform);
} catch {
return false;
}
}
/** true — доступен нативный сканер Telegram (мобильный клиент). */
export function canUseTelegramScanner(): boolean {
try {
return isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable();
} catch {
return false;
}
}
/**
* Открывает нативный сканер Telegram. Возвращает распознанную строку либо null,
* если пользователь закрыл сканер (или он недоступен).
*/
export async function scanWithTelegram(
text: string,
capture?: (value: string) => boolean,
): Promise<string | null> {
const result = await openQrScanner({ text, capture: capture ?? (() => true) });
return result ?? null;
}
/** Подгружает html5-qrcode с CDN (в бандл не тянем — нужен редко). */
export async function loadHtml5Qrcode(): Promise<WindowWithHtml5['Html5Qrcode'] | undefined> {
const w = window as WindowWithHtml5;
if (w.Html5Qrcode) return w.Html5Qrcode;
const script = document.createElement('script');
script.src = HTML5_QRCODE_CDN;
script.integrity = HTML5_QRCODE_SRI;
script.crossOrigin = 'anonymous';
script.referrerPolicy = 'no-referrer';
document.head.appendChild(script);
await new Promise<void>((resolve) => {
script.onload = () => resolve();
// onerror срабатывает и при провале проверки целостности — вызывающий
// получит undefined и покажет «камера недоступна».
script.onerror = () => resolve();
});
return w.Html5Qrcode;
}
/**
* Достаёт код подарка из отсканированной строки.
*
* Один и тот же подарок распространяется тремя способами, и скан должен понимать
* все: deep-link бота (``?start=GIFT_<code>``), ссылка кабинета
* (``/gift?tab=activate&code=<code>``) и просто код (``GIFT-xxxx`` или голый).
*/
export function parseGiftCode(raw: string | null | undefined): string | null {
const value = (raw || '').trim();
if (!value) return null;
const startParam = value.match(/[?&]start=GIFT_([A-Za-z0-9_-]+)/i);
if (startParam) return startParam[1];
const cabinetParam = value.match(/[?&]code=([A-Za-z0-9_-]+)/i);
if (cabinetParam) return cabinetParam[1];
// Голый код: допускаем префикс GIFT- и разделители, но не произвольный текст —
// иначе сканер «распознал» бы любую ссылку и подставил мусор в поле.
const bare = value.replace(/^GIFT[-_]/i, '');
if (/^[A-Za-z0-9]{6,64}$/.test(bare)) return bare;
return null;
}