Merge pull request #419 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-05-04 20:57:31 +03:00
committed by GitHub
13 changed files with 1258 additions and 11 deletions

View File

@@ -540,6 +540,17 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ── Revoke (reissue) ────────────────────────────────────────────────
revokeSubscription: async (subscriptionId?: number) => {
const response = await apiClient.post(
'/cabinet/subscription/revoke',
undefined,
withSubId(subscriptionId),
);
return response.data;
},
// ── Daily subscription ────────────────────────────────────────────── // ── Daily subscription ──────────────────────────────────────────────
togglePause: async ( togglePause: async (

View File

@@ -295,6 +295,175 @@ export default function PaymentMethodIcon({
); );
} }
case 'etoplatezhi': {
const etoplatezhiGradId = `${uid}-etoplatezhi`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={etoplatezhiGradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${etoplatezhiGradId})`} />
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
<text x="20" y="26" textAnchor="middle" fontSize="14">
EP
</text>
</g>
</svg>
);
}
case 'antilopay': {
const antilopayGradId = `${uid}-antilopay`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={antilopayGradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#d97706" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${antilopayGradId})`} />
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
<text x="20" y="26" textAnchor="middle" fontSize="14">
AL
</text>
</g>
</svg>
);
}
case 'jupiter': {
const jupiterGradId = `${uid}-jupiter`;
const jupiterRingId = `${uid}-jupiter-ring`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={jupiterGradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7c3aed" />
<stop offset="55%" stopColor="#6366f1" />
<stop offset="100%" stopColor="#0ea5e9" />
</linearGradient>
<linearGradient id={jupiterRingId} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#fde68a" stopOpacity="0.9" />
<stop offset="100%" stopColor="#fde68a" stopOpacity="0.45" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${jupiterGradId})`} />
<ellipse
cx="20"
cy="22"
rx="15"
ry="3.4"
fill="none"
stroke={`url(#${jupiterRingId})`}
strokeWidth="2"
transform="rotate(-18 20 22)"
/>
<circle cx="20" cy="20" r="6.5" fill="#fde68a" opacity="0.95" />
</svg>
);
}
case 'donut': {
const donutBgGradId = `${uid}-donut-bg`;
const donutGlazeGradId = `${uid}-donut-glaze`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={donutBgGradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#fdf2f8" />
<stop offset="100%" stopColor="#fbcfe8" />
</linearGradient>
<radialGradient id={donutGlazeGradId} cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="#fb7185" />
<stop offset="60%" stopColor="#ec4899" />
<stop offset="100%" stopColor="#be185d" />
</radialGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${donutBgGradId})`} />
<circle cx="20" cy="20" r="13" fill={`url(#${donutGlazeGradId})`} />
<circle cx="20" cy="20" r="4.5" fill="#fdf2f8" />
<g fill="#fde68a">
<rect
x="13.5"
y="11"
width="2.5"
height="1"
rx="0.5"
transform="rotate(-30 14.75 11.5)"
/>
<rect x="22" y="13" width="2.5" height="1" rx="0.5" transform="rotate(20 23.25 13.5)" />
<rect
x="26"
y="20"
width="2.5"
height="1"
rx="0.5"
transform="rotate(-15 27.25 20.5)"
/>
</g>
<g fill="#a7f3d0">
<rect x="11" y="22" width="2.5" height="1" rx="0.5" transform="rotate(40 12.25 22.5)" />
<rect
x="20"
y="26"
width="2.5"
height="1"
rx="0.5"
transform="rotate(-30 21.25 26.5)"
/>
</g>
<g fill="#bfdbfe">
<rect x="16" y="27" width="2.5" height="1" rx="0.5" transform="rotate(15 17.25 27.5)" />
<rect x="24" y="9" width="2.5" height="1" rx="0.5" transform="rotate(-25 25.25 9.5)" />
</g>
</svg>
);
}
case 'lava': {
const lavaBgGradId = `${uid}-lava-bg`;
const lavaFlowGradId = `${uid}-lava-flow`;
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id={lavaBgGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1f2937" />
<stop offset="100%" stopColor="#0b0b0f" />
</linearGradient>
<linearGradient id={lavaFlowGradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#fde047" />
<stop offset="35%" stopColor="#fb923c" />
<stop offset="75%" stopColor="#ef4444" />
<stop offset="100%" stopColor="#7f1d1d" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill={`url(#${lavaBgGradId})`} />
<path
d="M7 27c2.2-1.6 4-3.5 5.4-5.7 1-1.6 1.6-3.4 2-5.2.4-1.7.7-3.5 1.7-4.9 1-1.4 2.6-2.3 4.3-2 1.6.3 2.8 1.6 3.4 3.1.6 1.5.8 3.2 1.6 4.7.9 1.7 2.5 3 4.3 3.6 1.5.5 3.1.5 4.6.6V33H7Z"
fill={`url(#${lavaFlowGradId})`}
/>
<circle cx="14" cy="14" r="1.6" fill="#fde047" opacity="0.85" />
<circle cx="22" cy="10" r="1.1" fill="#fb923c" opacity="0.8" />
<circle cx="29" cy="16" r="1.3" fill="#fde047" opacity="0.7" />
</svg>
);
}
case 'apple_iap':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#000000" />
<path
d="M25.8 20.2c-.04-3.56 2.9-5.26 3.04-5.36-1.66-2.42-4.24-2.76-5.16-2.8-2.2-.22-4.28 1.3-5.4 1.3-1.12 0-2.84-1.26-4.68-1.22-2.4.04-4.62 1.4-5.86 3.56-2.5 4.34-.64 10.76 1.8 14.28 1.18 1.72 2.6 3.64 4.46 3.58 1.78-.08 2.46-1.16 4.62-1.16 2.16 0 2.78 1.16 4.68 1.12 1.92-.04 3.14-1.74 4.32-3.48 1.36-2 1.92-3.92 1.96-4.02-.04-.02-3.76-1.44-3.78-5.8zM22.24 10.2c.98-1.2 1.64-2.84 1.46-4.5-1.42.06-3.12.94-4.14 2.14-.9 1.06-1.7 2.74-1.48 4.36 1.58.12 3.18-.8 4.16-2z"
fill="#fff"
/>
</svg>
);
default: default:
return ( return (
<svg className={className} viewBox="0 0 40 40"> <svg className={className} viewBox="0 0 40 40">

View File

@@ -55,6 +55,12 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
{ id: 'payments_paypear', categories: ['PAYPEAR'] }, { id: 'payments_paypear', categories: ['PAYPEAR'] },
{ id: 'payments_rollypay', categories: ['ROLLYPAY'] }, { id: 'payments_rollypay', categories: ['ROLLYPAY'] },
{ id: 'payments_aurapay', categories: ['AURAPAY'] }, { id: 'payments_aurapay', categories: ['AURAPAY'] },
{ id: 'payments_etoplatezhi', categories: ['ETOPLATEZHI'] },
{ id: 'payments_antilopay', categories: ['ANTILOPAY'] },
{ id: 'payments_jupiter', categories: ['JUPITER'] },
{ id: 'payments_donut', categories: ['DONUT'] },
{ id: 'payments_lava', categories: ['LAVA'] },
{ id: 'payments_apple_iap', categories: ['APPLE_IAP'] },
], ],
}, },
{ {

View File

@@ -0,0 +1,287 @@
import { useMemo, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
interface PreviewButton {
text: string;
url?: string;
callback_data?: string;
}
interface TelegramPreviewProps {
open: boolean;
onClose: () => void;
text: string;
mediaUrl?: string | null;
mediaType?: 'photo' | 'video' | null;
buttons?: PreviewButton[][];
}
interface EmailPreviewProps {
open: boolean;
onClose: () => void;
subject: string;
htmlContent: string;
}
interface Token {
kind: 'text' | 'open' | 'close' | 'br';
tag?: string;
href?: string;
value?: string;
}
const TG_TAGS = new Set([
'b',
'strong',
'i',
'em',
'u',
'ins',
's',
'strike',
'del',
'code',
'pre',
'a',
'tg-spoiler',
'span',
]);
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
const pattern = /<(\/?)([a-z][a-z0-9-]*)(\s+[^>]*)?>|<br\s*\/?>/gi;
const matches = [...input.matchAll(pattern)];
let lastIdx = 0;
for (const m of matches) {
const idx = m.index || 0;
if (idx > lastIdx) tokens.push({ kind: 'text', value: input.slice(lastIdx, idx) });
if (m[0].toLowerCase().startsWith('<br')) {
tokens.push({ kind: 'br' });
} else {
const isClose = m[1] === '/';
const tag = m[2].toLowerCase();
if (!TG_TAGS.has(tag)) {
tokens.push({ kind: 'text', value: m[0] });
} else if (isClose) {
tokens.push({ kind: 'close', tag });
} else {
let href: string | undefined;
if (tag === 'a' && m[3]) {
const hrefMatch = m[3].match(/href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i);
href = hrefMatch ? hrefMatch[1] || hrefMatch[2] : undefined;
}
tokens.push({ kind: 'open', tag, href });
}
}
lastIdx = idx + m[0].length;
}
if (lastIdx < input.length) tokens.push({ kind: 'text', value: input.slice(lastIdx) });
return tokens;
}
function renderText(value: string, key: number): ReactNode {
const parts = value.split(/\n/);
return parts.flatMap((p, i) => (i === 0 ? [p] : [<br key={`nl-${key}-${i}`} />, p]));
}
type Frame = { tag: string | null; href?: string; children: ReactNode[] };
function wrap(frame: Frame, key: number): ReactNode {
const k = `el-${key}`;
switch (frame.tag) {
case 'b':
case 'strong':
return <b key={k}>{frame.children}</b>;
case 'i':
case 'em':
return <i key={k}>{frame.children}</i>;
case 'u':
case 'ins':
return <u key={k}>{frame.children}</u>;
case 's':
case 'strike':
case 'del':
return <s key={k}>{frame.children}</s>;
case 'code':
return (
<code key={k} className="rounded bg-black/30 px-1 font-mono text-[0.92em]">
{frame.children}
</code>
);
case 'pre':
return (
<pre key={k} className="my-1 rounded bg-black/30 p-2 font-mono text-[0.92em]">
{frame.children}
</pre>
);
case 'a': {
const safeHref =
frame.href && /^(https?:|tg:|mailto:|tel:)/i.test(frame.href) ? frame.href : '#';
return (
<a
key={k}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 underline"
>
{frame.children}
</a>
);
}
case 'tg-spoiler':
case 'span':
return <span key={k}>{frame.children}</span>;
default:
return <span key={k}>{frame.children}</span>;
}
}
function tokensToReact(tokens: Token[]): ReactNode {
const root: Frame = { tag: null, children: [] };
const stack: Frame[] = [root];
let textKey = 0;
let elKey = 0;
for (const tok of tokens) {
const top = stack[stack.length - 1];
if (tok.kind === 'text') {
top.children.push(<span key={`t-${textKey++}`}>{renderText(tok.value || '', textKey)}</span>);
} else if (tok.kind === 'br') {
top.children.push(<br key={`br-${elKey++}`} />);
} else if (tok.kind === 'open') {
stack.push({ tag: tok.tag!, href: tok.href, children: [] });
} else if (tok.kind === 'close') {
let foundIdx = -1;
for (let i = stack.length - 1; i >= 1; i--) {
if (stack[i].tag === tok.tag) {
foundIdx = i;
break;
}
}
if (foundIdx === -1) continue;
while (stack.length > foundIdx) {
const closed = stack.pop()!;
const parent = stack[stack.length - 1] || root;
parent.children.push(wrap(closed, elKey++));
}
}
}
while (stack.length > 1) {
const closed = stack.pop()!;
const parent = stack[stack.length - 1];
parent.children.push(wrap(closed, elKey++));
}
return root.children;
}
export function TelegramPreview({
open,
onClose,
text,
mediaUrl,
mediaType,
buttons,
}: TelegramPreviewProps) {
const { t } = useTranslation();
const rendered = useMemo(() => tokensToReact(tokenize(text)), [text]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-[#17212b] p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-semibold text-white">
{t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
</h3>
<button onClick={onClose} className="rounded p-1 text-dark-400 hover:bg-dark-700">
</button>
</div>
<div className="rounded-xl bg-[#0e1621] p-3">
<div className="ml-auto max-w-[90%] rounded-2xl rounded-tr-md bg-[#2b5278] p-3 text-white shadow">
{mediaUrl && mediaType === 'photo' && (
<img src={mediaUrl} alt="" className="mb-2 max-h-72 w-full rounded-lg object-cover" />
)}
{mediaUrl && mediaType === 'video' && (
<video src={mediaUrl} controls className="mb-2 max-h-72 w-full rounded-lg" />
)}
{text ? (
<div className="whitespace-pre-wrap break-words text-[15px] leading-snug">
{rendered}
</div>
) : (
<div className="text-sm italic text-white/60">
{t('admin.broadcasts.previewEmpty', '— пусто —')}
</div>
)}
</div>
{buttons && buttons.length > 0 && (
<div className="mt-2 space-y-1">
{buttons.map((row, ri) => (
<div key={ri} className="flex gap-1">
{row.map((b, ci) => (
<button
key={ci}
type="button"
className="flex-1 rounded-md bg-[#1f2c3a] px-3 py-2 text-sm text-blue-300 hover:bg-[#26384a]"
>
{b.text}
</button>
))}
</div>
))}
</div>
)}
</div>
</div>
</div>,
document.body,
);
}
export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) {
const { t } = useTranslation();
if (!open) return null;
const emptyHtml = `<p style="color:#999;font-family:sans-serif">${t('admin.broadcasts.previewEmpty', '— пусто —')}</p>`;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 p-4"
onClick={onClose}
>
<div
className="flex h-[80vh] w-full max-w-2xl flex-col rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<div className="min-w-0 flex-1">
<div className="text-xs uppercase tracking-wider text-gray-400">
{t('admin.broadcasts.emailSubject', 'Тема')}
</div>
<div className="truncate text-base font-semibold text-gray-900">
{subject || t('admin.broadcasts.previewEmpty', '— пусто —')}
</div>
</div>
<button
onClick={onClose}
className="ml-3 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
</button>
</div>
<iframe
title="email preview"
className="w-full flex-1 bg-white"
sandbox=""
srcDoc={htmlContent || emptyHtml}
/>
</div>
</div>,
document.body,
);
}

View File

@@ -11,6 +11,7 @@ import type {
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
import type { BlockRendererProps } from './blocks'; import type { BlockRendererProps } from './blocks';
import TvQuickConnect from './TvQuickConnect';
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
@@ -145,6 +146,12 @@ export default function InstallationGuide({
], ],
); );
const selectedIsTv =
(activePlatformKey || availablePlatforms[0]) === 'androidTV' ||
(activePlatformKey || availablePlatforms[0]) === 'appleTV';
const userIsOnTv = detectedPlatform === 'androidTV' || detectedPlatform === 'appleTV';
const isTvPlatform = selectedIsTv && !userIsOnTv;
const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformKey = activePlatformKey || availablePlatforms[0];
const currentPlatformData = currentPlatformKey const currentPlatformData = currentPlatformKey
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined) ? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)
@@ -328,8 +335,32 @@ export default function InstallationGuide({
</a> </a>
)} )}
{/* Blocks */} {/* Blocks — for TV: first block, Quick Connect, last block */}
{selectedApp && ( {selectedApp && isTvPlatform && appConfig.subscriptionUrl ? (
<>
{selectedApp.blocks.length > 0 && (
<Renderer
blocks={selectedApp.blocks.slice(0, 1)}
isMobile={isMobile}
isLight={isLight}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
<TvQuickConnect subscriptionUrl={appConfig.subscriptionUrl} isLight={isLight} />
{selectedApp.blocks.length > 1 && (
<Renderer
blocks={selectedApp.blocks.slice(-1)}
isMobile={isMobile}
isLight={isLight}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
</>
) : selectedApp ? (
<Renderer <Renderer
blocks={selectedApp.blocks} blocks={selectedApp.blocks}
isMobile={isMobile} isMobile={isMobile}
@@ -338,7 +369,7 @@ export default function InstallationGuide({
getSvgHtml={getSvgHtml} getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons} renderBlockButtons={renderBlockButtons}
/> />
)} ) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,354 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
openQrScanner,
isQrScannerSupported,
retrieveLaunchParams,
} from '@telegram-apps/sdk-react';
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
function isTelegramMobile(): boolean {
try {
const platform = retrieveLaunchParams().tgWebAppPlatform;
return TG_MOBILE_PLATFORMS.has(platform);
} catch {
return false;
}
}
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 {
subscriptionUrl: string;
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) {
const { t } = useTranslation();
const [code, setCode] = useState('');
const [sending, setSending] = useState(false);
const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [scanning, setScanning] = useState(false);
const [tgNative, setTgNative] = useState(false);
const toastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const scannerRef = useRef<Html5QrcodeInstance | null>(null);
useEffect(() => {
try {
setTgNative(isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable());
} catch {
setTgNative(false);
}
return () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
if (scannerRef.current) {
scannerRef.current
.stop()
.catch(() => undefined)
.finally(() => {
scannerRef.current?.clear();
scannerRef.current = null;
});
}
};
}, []);
const showToast = useCallback((text: string, type: 'success' | 'error') => {
setToast({ text, type });
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => setToast(null), 3000);
}, []);
const sendToTV = useCallback(
async (tvCode: string) => {
if (sending) return;
const clean = tvCode.trim().toUpperCase();
if (!(clean.length === 5 && /^[A-Z0-9]+$/.test(clean))) {
showToast(t('subscription.tvQuickConnect.badCode'), 'error');
return;
}
setSending(true);
try {
const b64 = btoa(unescape(encodeURIComponent(subscriptionUrl)));
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10000);
const res = await fetch(`${HAPP_TV_API}/${encodeURIComponent(clean)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: b64 }),
signal: ctrl.signal,
});
clearTimeout(timer);
if (res.ok) {
showToast(t('subscription.tvQuickConnect.sent'), 'success');
setCode('');
} else {
showToast(t('subscription.tvQuickConnect.error'), 'error');
}
} catch {
showToast(t('subscription.tvQuickConnect.error'), 'error');
} finally {
setSending(false);
}
},
[sending, subscriptionUrl, showToast, t],
);
const stopScan = useCallback(() => {
if (scannerRef.current) {
scannerRef.current
.stop()
.catch(() => undefined)
.finally(() => {
scannerRef.current?.clear();
scannerRef.current = null;
});
}
setScanning(false);
}, []);
const onScanDecoded = useCallback(
(decoded: string) => {
const parsed = parseQRCode(decoded);
if (!parsed) return;
stopScan();
setCode(parsed);
showToast(`${t('subscription.tvQuickConnect.codeFound')}: ${parsed}`, 'success');
setTimeout(() => sendToTV(parsed), 500);
},
[stopScan, showToast, sendToTV, t],
);
const startScan = useCallback(async () => {
// Telegram Mini App: native scanner
if (tgNative) {
try {
const qr = await openQrScanner({
text: t('subscription.tvQuickConnect.scanDescription'),
capture: (s: string) => parseQRCode(s) !== null,
});
if (qr) {
const parsed = parseQRCode(qr);
if (parsed) {
setCode(parsed);
showToast(t('subscription.tvQuickConnect.codeFound'), 'success');
sendToTV(parsed);
}
}
} catch {
showToast(t('subscription.tvQuickConnect.error'), 'error');
}
return;
}
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
const w = window as WindowWithHtml5;
if (!w.Html5Qrcode) {
const script = document.createElement('script');
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');
return;
}
setScanning(true);
const scanner = new w.Html5Qrcode('tv-qr-reader');
scannerRef.current = scanner;
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
try {
await scanner.start({ facingMode: 'environment' }, config, onScanDecoded, () => undefined);
} catch {
try {
await scanner.start({ facingMode: 'user' }, config, onScanDecoded, () => undefined);
} catch {
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
scannerRef.current = null;
setScanning(false);
}
}
}, [tgNative, sendToTV, showToast, onScanDecoded, t]);
const cardClass = isLight
? 'rounded-2xl border border-dark-700/60 bg-white/80 shadow-sm p-4 sm:p-5'
: 'rounded-2xl border border-dark-700/50 bg-dark-800/50 p-4 sm:p-5';
const inputClass = isLight
? 'w-full rounded-xl border border-dark-700/60 bg-white px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500'
: 'w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] uppercase text-dark-100 outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500';
return (
<div className="space-y-3">
{/* Code input */}
<div className={cardClass}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10">
<svg
className="h-5 w-5 text-accent-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-dark-100">
{t('subscription.tvQuickConnect.title')}
</h3>
<p className="mt-1 text-sm text-dark-400">
{t('subscription.tvQuickConnect.description')}
</p>
<div className="mt-3 space-y-2">
<input
type="text"
maxLength={5}
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
placeholder="A1B2C"
autoComplete="one-time-code"
inputMode="text"
className={inputClass}
/>
<button
onClick={() => sendToTV(code)}
disabled={sending || code.length !== 5}
className="btn-primary w-full justify-center py-3 disabled:opacity-50"
>
{sending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
t('subscription.tvQuickConnect.sendBtn')
)}
</button>
</div>
</div>
</div>
</div>
{/* QR Scanner */}
<div className={cardClass}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10">
<svg
className="h-5 w-5 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-dark-100">
{t('subscription.tvQuickConnect.scanTitle')}
</h3>
<p className="mt-1 text-sm text-dark-400">
{t('subscription.tvQuickConnect.scanDescription')}
</p>
{!scanning && (
<button onClick={startScan} className="btn-secondary mt-3 w-full justify-center py-3">
<svg
className="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
{t('subscription.tvQuickConnect.scanBtn')}
</button>
)}
<div className={scanning ? 'mt-3 space-y-2' : 'hidden'}>
<div id="tv-qr-reader" className="overflow-hidden rounded-xl" />
{scanning && (
<button onClick={stopScan} className="btn-secondary w-full justify-center py-2.5">
{t('subscription.tvQuickConnect.stopScan')}
</button>
)}
</div>
</div>
</div>
</div>
{/* Toast */}
{toast && (
<div
className={`fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl px-5 py-3 text-sm font-medium shadow-lg transition-all ${
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
}`}
>
{toast.text}
</div>
)}
</div>
);
}
function parseQRCode(data: string): string | null {
if (data.length === 5 && /^[A-Z0-9]+$/i.test(data)) {
return data.toUpperCase();
}
try {
const url = new URL(data);
const parts = url.pathname.split('/').filter((p) => p.length > 0);
if (parts.length > 0) {
const last = parts[parts.length - 1];
if (last.length === 5 && /^[A-Z0-9]+$/i.test(last)) return last.toUpperCase();
}
const codeParam = url.searchParams.get('code');
if (codeParam?.length === 5 && /^[A-Z0-9]+$/i.test(codeParam)) return codeParam.toUpperCase();
} catch {
const m = data.match(/[/=]([A-Z0-9]{5})(?:[/?&\s]|$)/i);
if (m?.[1]) return m[1].toUpperCase();
}
return null;
}

View File

@@ -18,5 +18,11 @@ export const METHOD_LABELS: Record<string, string> = {
paypear: 'PayPear', paypear: 'PayPear',
rollypay: 'RollyPay', rollypay: 'RollyPay',
aurapay: 'AuraPay', aurapay: 'AuraPay',
etoplatezhi: 'Etoplatezhi',
antilopay: 'Antilopay',
jupiter: 'Jupiter',
donut: 'Donut',
lava: 'Lava',
apple_iap: 'Apple In-App Purchase',
manual: 'Ручное пополнение', manual: 'Ручное пополнение',
}; };

View File

@@ -571,6 +571,14 @@
"selectServersHint": "Select servers to connect or disconnect", "selectServersHint": "Select servers to connect or disconnect",
"noServersAvailable": "No servers available to manage" "noServersAvailable": "No servers available to manage"
}, },
"revoke": {
"title": "Reissue Subscription",
"button": "Reissue Subscription",
"description": "New link, device reset",
"warning": "This will generate a new connection link, reset all connected devices, and invalidate the old link. Continue?",
"confirmBtn": "Reissue",
"cooldown": "Available in {{minutes}} min {{seconds}} sec"
},
"legacy": { "legacy": {
"selectTariffTitle": "Select a tariff to renew", "selectTariffTitle": "Select a tariff to renew",
"selectTariffDescription": "Your current subscription was created before tariffs were introduced. To renew, please select one of the available tariffs.", "selectTariffDescription": "Your current subscription was created before tariffs were introduced. To renew, please select one of the available tariffs.",
@@ -593,6 +601,20 @@
"label": "Traffic", "label": "Traffic",
"selectVolume": "Select traffic volume", "selectVolume": "Select traffic volume",
"default": "Default: {{label}}" "default": "Default: {{label}}"
},
"tvQuickConnect": {
"title": "Connect TV",
"description": "Enter the 5-character code from your TV screen",
"sendBtn": "Send to TV",
"badCode": "Enter a 5-character code (letters and digits)",
"sent": "Sent to TV!",
"error": "Failed to send to TV",
"scanTitle": "Scan QR Code",
"scanDescription": "Scan the QR code from your TV screen",
"scanBtn": "Scan QR from TV",
"stopScan": "Close camera",
"noCamera": "Camera access denied",
"codeFound": "Code found"
} }
}, },
"balance": { "balance": {
@@ -659,6 +681,24 @@
}, },
"aurapay": { "aurapay": {
"description": "Pay via AuraPay" "description": "Pay via AuraPay"
},
"etoplatezhi": {
"description": "Pay via Etoplatezhi"
},
"antilopay": {
"description": "Pay via Antilopay"
},
"jupiter": {
"description": "Pay via SBP through Jupiter (FPGate P2P)"
},
"donut": {
"description": "Pay by card or SBP through Donut P2P"
},
"lava": {
"description": "Pay by card or SBP through Lava Business"
},
"apple_iap": {
"description": "Pay via Apple In-App Purchase"
} }
}, },
"transactionHistory": "Transaction History", "transactionHistory": "Transaction History",
@@ -1763,7 +1803,17 @@
"customButtonTypeCallback": "Callback", "customButtonTypeCallback": "Callback",
"customButtonTypeUrl": "URL", "customButtonTypeUrl": "URL",
"customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)", "customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)",
"customButtonUrlPlaceholder": "https://example.com" "customButtonUrlPlaceholder": "https://example.com",
"preview": "Preview",
"previewEmpty": "— empty —",
"emailSubjectLabel": "Subject",
"btnBalance": "Top up balance",
"btnPartners": "Referral program",
"btnPromocode": "Promo code",
"btnConnect": "Connect",
"btnSubscription": "Subscription",
"btnSupport": "Support",
"btnHome": "Home"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Pinned Messages", "title": "Pinned Messages",
@@ -1971,6 +2021,12 @@
"payments_paypear": "PayPear", "payments_paypear": "PayPear",
"payments_rollypay": "RollyPay", "payments_rollypay": "RollyPay",
"payments_aurapay": "AuraPay", "payments_aurapay": "AuraPay",
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
"subs_core": "Core", "subs_core": "Core",
"subs_trial": "Trial", "subs_trial": "Trial",
"subs_pricing": "Pricing", "subs_pricing": "Pricing",

View File

@@ -402,6 +402,14 @@
"selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید", "selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید",
"noServersAvailable": "سرور قابل مدیریتی وجود ندارد" "noServersAvailable": "سرور قابل مدیریتی وجود ندارد"
}, },
"revoke": {
"title": "صدور مجدد اشتراک",
"button": "صدور مجدد اشتراک",
"description": "لینک جدید، بازنشانی دستگاه‌ها",
"warning": "این عمل لینک اتصال جدیدی تولید می‌کند، همه دستگاه‌های متصل را بازنشانی می‌کند و لینک قدیمی را نامعتبر می‌کند. ادامه؟",
"confirmBtn": "صدور مجدد",
"cooldown": "در {{minutes}} دقیقه {{seconds}} ثانیه دیگر"
},
"legacy": { "legacy": {
"selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید", "selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید",
"selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفه‌ها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفه‌های موجود را انتخاب کنید.", "selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفه‌ها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفه‌های موجود را انتخاب کنید.",
@@ -472,6 +480,20 @@
"resumeBtn": "ادامه", "resumeBtn": "ادامه",
"title": "توقف اشتراک", "title": "توقف اشتراک",
"willBeCharged": "کسر خواهد شد" "willBeCharged": "کسر خواهد شد"
},
"tvQuickConnect": {
"title": "اتصال تلویزیون",
"description": "کد ۵ رقمی نمایش داده شده روی صفحه تلویزیون را وارد کنید",
"sendBtn": "ارسال به تلویزیون",
"badCode": "کد ۵ رقمی وارد کنید (حروف و اعداد)",
"sent": "به تلویزیون ارسال شد!",
"error": "خطا در ارسال به تلویزیون",
"scanTitle": "اسکن کد QR",
"scanDescription": "کد QR روی صفحه تلویزیون را اسکن کنید",
"scanBtn": "اسکن QR از تلویزیون",
"stopScan": "بستن دوربین",
"noCamera": "دسترسی به دوربین امکان‌پذیر نیست",
"codeFound": "کد پیدا شد"
} }
}, },
"balance": { "balance": {
@@ -496,6 +518,24 @@
}, },
"severpay": { "severpay": {
"description": "پرداخت از طریق SeverPay" "description": "پرداخت از طریق SeverPay"
},
"etoplatezhi": {
"description": "پرداخت از طریق Etoplatezhi"
},
"antilopay": {
"description": "پرداخت از طریق Antilopay"
},
"jupiter": {
"description": "پرداخت SBP از طریق Jupiter (FPGate P2P)"
},
"donut": {
"description": "پرداخت با کارت یا SBP از طریق Donut P2P"
},
"lava": {
"description": "پرداخت با کارت یا SBP از طریق Lava Business"
},
"apple_iap": {
"description": "پرداخت از طریق Apple In-App Purchase"
} }
}, },
"transactionHistory": "تاریخچه تراکنش‌ها", "transactionHistory": "تاریخچه تراکنش‌ها",
@@ -1621,6 +1661,12 @@
"payments_paypear": "PayPear", "payments_paypear": "PayPear",
"payments_rollypay": "RollyPay", "payments_rollypay": "RollyPay",
"payments_aurapay": "AuraPay", "payments_aurapay": "AuraPay",
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
"subs_core": "هسته", "subs_core": "هسته",
"subs_trial": "آزمایشی", "subs_trial": "آزمایشی",
"subs_pricing": "قیمت‌گذاری", "subs_pricing": "قیمت‌گذاری",

View File

@@ -599,6 +599,14 @@
"selectServersHint": "Выберите серверы для подключения или отключения", "selectServersHint": "Выберите серверы для подключения или отключения",
"noServersAvailable": "Нет доступных серверов для управления" "noServersAvailable": "Нет доступных серверов для управления"
}, },
"revoke": {
"title": "Перевыпуск подписки",
"button": "Перевыпустить подписку",
"description": "Новая ссылка, сброс устройств",
"warning": "Это действие сгенерирует новую ссылку подключения, сбросит все подключённые устройства и сделает старую ссылку недействительной. Продолжить?",
"confirmBtn": "Перевыпустить",
"cooldown": "Доступно через {{minutes}} мин. {{seconds}} сек."
},
"legacy": { "legacy": {
"selectTariffTitle": "Выберите тариф для продления", "selectTariffTitle": "Выберите тариф для продления",
"selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.", "selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.",
@@ -621,6 +629,20 @@
"label": "Трафик", "label": "Трафик",
"selectVolume": "Выбрать объём трафика", "selectVolume": "Выбрать объём трафика",
"default": "По умолчанию: {{label}}" "default": "По умолчанию: {{label}}"
},
"tvQuickConnect": {
"title": "Подключить TV",
"description": "Введите 5-значный код с экрана телевизора",
"sendBtn": "Отправить на TV",
"badCode": "Введите 5-значный код (буквы и цифры)",
"sent": "Отправлено на TV!",
"error": "Ошибка отправки на TV",
"scanTitle": "Сканировать QR-код",
"scanDescription": "Отсканируйте QR-код с экрана телевизора",
"scanBtn": "Сканировать QR с TV",
"stopScan": "Закрыть камеру",
"noCamera": "Нет доступа к камере",
"codeFound": "Код найден"
} }
}, },
"balance": { "balance": {
@@ -687,6 +709,24 @@
}, },
"aurapay": { "aurapay": {
"description": "Оплата через AuraPay" "description": "Оплата через AuraPay"
},
"etoplatezhi": {
"description": "Оплата через Etoplatezhi"
},
"antilopay": {
"description": "Оплата через Antilopay"
},
"jupiter": {
"description": "Оплата СБП через Jupiter (FPGate P2P)"
},
"donut": {
"description": "Оплата картой и СБП через Donut P2P"
},
"lava": {
"description": "Оплата картой и СБП через Lava Business"
},
"apple_iap": {
"description": "Оплата через Apple In-App Purchase"
} }
}, },
"transactionHistory": "История операций", "transactionHistory": "История операций",
@@ -1788,7 +1828,17 @@
"customButtonTypeCallback": "Callback", "customButtonTypeCallback": "Callback",
"customButtonTypeUrl": "Ссылка", "customButtonTypeUrl": "Ссылка",
"customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)", "customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)",
"customButtonUrlPlaceholder": "https://example.com" "customButtonUrlPlaceholder": "https://example.com",
"preview": "Предпросмотр",
"previewEmpty": "— пусто —",
"emailSubjectLabel": "Тема",
"btnBalance": "Пополнить баланс",
"btnPartners": "Партнёрка",
"btnPromocode": "Промокод",
"btnConnect": "Подключиться",
"btnSubscription": "Подписка",
"btnSupport": "Техподдержка",
"btnHome": "На главную"
}, },
"pinnedMessages": { "pinnedMessages": {
"title": "Закреплённые сообщения", "title": "Закреплённые сообщения",
@@ -1987,6 +2037,12 @@
"payments_paypear": "PayPear", "payments_paypear": "PayPear",
"payments_rollypay": "RollyPay", "payments_rollypay": "RollyPay",
"payments_aurapay": "AuraPay", "payments_aurapay": "AuraPay",
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
"subs_core": "Основные", "subs_core": "Основные",
"subs_trial": "Пробный период", "subs_trial": "Пробный период",
"subs_pricing": "Цены", "subs_pricing": "Цены",

View File

@@ -402,6 +402,14 @@
"selectServersHint": "选择要连接或断开的服务器", "selectServersHint": "选择要连接或断开的服务器",
"noServersAvailable": "没有可管理的服务器" "noServersAvailable": "没有可管理的服务器"
}, },
"revoke": {
"title": "重新签发订阅",
"button": "重新签发订阅",
"description": "新链接,设备重置",
"warning": "此操作将生成新的连接链接,重置所有已连接设备,并使旧链接失效。继续?",
"confirmBtn": "重新签发",
"cooldown": "{{minutes}}分{{seconds}}秒后可用"
},
"legacy": { "legacy": {
"selectTariffTitle": "选择续费套餐", "selectTariffTitle": "选择续费套餐",
"selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。", "selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。",
@@ -472,6 +480,20 @@
"resumeBtn": "恢复", "resumeBtn": "恢复",
"title": "暂停订阅", "title": "暂停订阅",
"willBeCharged": "将扣费" "willBeCharged": "将扣费"
},
"tvQuickConnect": {
"title": "连接电视",
"description": "输入电视屏幕上显示的5位代码",
"sendBtn": "发送到电视",
"badCode": "请输入5位代码字母和数字",
"sent": "已发送到电视!",
"error": "发送到电视失败",
"scanTitle": "扫描二维码",
"scanDescription": "扫描电视屏幕上的二维码",
"scanBtn": "扫描电视二维码",
"stopScan": "关闭摄像头",
"noCamera": "无法访问摄像头",
"codeFound": "找到代码"
} }
}, },
"balance": { "balance": {
@@ -496,6 +518,24 @@
}, },
"severpay": { "severpay": {
"description": "通过SeverPay支付" "description": "通过SeverPay支付"
},
"etoplatezhi": {
"description": "通过Etoplatezhi支付"
},
"antilopay": {
"description": "通过Antilopay支付"
},
"jupiter": {
"description": "通过 Jupiter (FPGate P2P) 使用 SBP 支付"
},
"donut": {
"description": "通过 Donut P2P 使用银行卡或 SBP 支付"
},
"lava": {
"description": "通过 Lava Business 使用银行卡或 SBP 支付"
},
"apple_iap": {
"description": "通过Apple应用内购买支付"
} }
}, },
"transactionHistory": "交易记录", "transactionHistory": "交易记录",
@@ -1659,6 +1699,12 @@
"payments_paypear": "PayPear", "payments_paypear": "PayPear",
"payments_rollypay": "RollyPay", "payments_rollypay": "RollyPay",
"payments_aurapay": "AuraPay", "payments_aurapay": "AuraPay",
"payments_etoplatezhi": "Etoplatezhi",
"payments_antilopay": "Antilopay",
"payments_jupiter": "Jupiter",
"payments_donut": "Donut",
"payments_lava": "Lava",
"payments_apple_iap": "Apple IAP",
"subs_core": "核心", "subs_core": "核心",
"subs_trial": "试用", "subs_trial": "试用",
"subs_pricing": "定价", "subs_pricing": "定价",

View File

@@ -10,6 +10,7 @@ import {
CustomBroadcastButton, CustomBroadcastButton,
} from '../api/adminBroadcasts'; } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview';
// Icons // Icons
const BroadcastIcon = () => ( const BroadcastIcon = () => (
@@ -160,6 +161,42 @@ export default function AdminBroadcastCreate() {
// Submitting state for dual send // Submitting state for dual send
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Preview modals
const [showTelegramPreview, setShowTelegramPreview] = useState(false);
const [showEmailPreview, setShowEmailPreview] = useState(false);
const previewMediaTypeForModal: 'photo' | 'video' | null =
mediaType === 'photo' || mediaType === 'video' ? mediaType : null;
const previewButtonRows = useMemo(() => {
const rows: { text: string; url?: string; callback_data?: string }[][] = [];
if (selectedButtons.length > 0) {
const presetLabels: Record<string, string> = {
balance: t('admin.broadcasts.btnBalance', 'Пополнить баланс'),
partners: t('admin.broadcasts.btnPartners', 'Партнёрка'),
promocode: t('admin.broadcasts.btnPromocode', 'Промокод'),
connect: t('admin.broadcasts.btnConnect', 'Подключиться'),
subscription: t('admin.broadcasts.btnSubscription', 'Подписка'),
support: t('admin.broadcasts.btnSupport', 'Техподдержка'),
home: t('admin.broadcasts.btnHome', 'На главную'),
};
for (const id of selectedButtons) {
rows.push([{ text: presetLabels[id] || id, callback_data: id }]);
}
}
for (const cb of customButtons) {
rows.push([
{
text: cb.label,
...(cb.action_type === 'url'
? { url: cb.action_value }
: { callback_data: cb.action_value }),
},
]);
}
return rows;
}, [selectedButtons, customButtons, t]);
// Fetch Telegram filters // Fetch Telegram filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({ const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'], queryKey: ['admin', 'broadcasts', 'filters'],
@@ -623,9 +660,19 @@ export default function AdminBroadcastCreate() {
{/* Telegram section */} {/* Telegram section */}
{telegramEnabled && ( {telegramEnabled && (
<div className="card space-y-6"> <div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100"> <div className="flex items-center justify-between gap-2">
{t('admin.broadcasts.telegramSection')} <h2 className="text-lg font-semibold text-dark-100">
</h2> {t('admin.broadcasts.telegramSection')}
</h2>
<button
type="button"
onClick={() => setShowTelegramPreview(true)}
disabled={messageText.trim().length === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{t('admin.broadcasts.preview', 'Предпросмотр')}
</button>
</div>
{/* Telegram filter selection */} {/* Telegram filter selection */}
{renderFilterDropdown( {renderFilterDropdown(
@@ -861,9 +908,19 @@ export default function AdminBroadcastCreate() {
{/* Email section */} {/* Email section */}
{emailEnabled && ( {emailEnabled && (
<div className="card space-y-6"> <div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100"> <div className="flex items-center justify-between gap-2">
{t('admin.broadcasts.emailSection')} <h2 className="text-lg font-semibold text-dark-100">
</h2> {t('admin.broadcasts.emailSection')}
</h2>
<button
type="button"
onClick={() => setShowEmailPreview(true)}
disabled={emailContent.trim().length === 0}
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{t('admin.broadcasts.preview', 'Предпросмотр')}
</button>
</div>
{/* Email filter selection */} {/* Email filter selection */}
{renderFilterDropdown( {renderFilterDropdown(
@@ -961,6 +1018,21 @@ export default function AdminBroadcastCreate() {
</button> </button>
</div> </div>
</div> </div>
<TelegramPreview
open={showTelegramPreview}
onClose={() => setShowTelegramPreview(false)}
text={messageText}
mediaUrl={mediaPreview}
mediaType={previewMediaTypeForModal}
buttons={previewButtonRows}
/>
<EmailPreview
open={showEmailPreview}
onClose={() => setShowEmailPreview(false)}
subject={emailSubject}
htmlContent={emailContent}
/>
</div> </div>
); );
} }

View File

@@ -202,6 +202,9 @@ export default function Subscription() {
// Traffic refresh state // Traffic refresh state
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0); const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0);
// Revoke (reissue) cooldown state
const [revokeCooldown, setRevokeCooldown] = useState(0);
const [trafficData, setTrafficData] = useState<{ const [trafficData, setTrafficData] = useState<{
traffic_used_gb: number; traffic_used_gb: number;
traffic_used_percent: number; traffic_used_percent: number;
@@ -464,6 +467,41 @@ export default function Subscription() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [trafficRefreshCooldown]); }, [trafficRefreshCooldown]);
// Initialize revoke cooldown from localStorage on mount
useEffect(() => {
const ts = localStorage.getItem(`revoke_ts_${subscriptionId ?? 'default'}`);
if (ts) {
const elapsed = Math.floor((Date.now() - parseInt(ts, 10)) / 1000);
const remaining = Math.max(0, 900 - elapsed);
setRevokeCooldown(remaining);
}
}, [subscriptionId]);
// Countdown timer for revoke cooldown
useEffect(() => {
if (revokeCooldown <= 0) return;
const timer = setInterval(() => {
setRevokeCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [revokeCooldown]);
// Revoke (reissue) subscription mutation
const revokeMutation = useMutation({
mutationFn: () => subscriptionApi.revokeSubscription(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
haptic.notification('success');
localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString());
setRevokeCooldown(900);
},
onError: () => {
haptic.notification('error');
},
});
// Auto-refresh traffic on mount (with 30s caching) // Auto-refresh traffic on mount (with 30s caching)
useEffect(() => { useEffect(() => {
if (!subscription) return; if (!subscription) return;
@@ -494,6 +532,16 @@ export default function Subscription() {
} }
}; };
const handleRevoke = async () => {
const confirmed = await destructiveConfirm(
t('subscription.revoke.warning'),
t('subscription.revoke.confirmBtn'),
t('subscription.revoke.title'),
);
if (!confirmed) return;
revokeMutation.mutate();
};
// In multi-tariff mode without a specific subscription ID, redirect to list // In multi-tariff mode without a specific subscription ID, redirect to list
if (isMultiTariff && !subscriptionId && !isLoading) { if (isMultiTariff && !subscriptionId && !isLoading) {
return <Navigate to="/subscriptions" replace />; return <Navigate to="/subscriptions" replace />;
@@ -2247,6 +2295,65 @@ export default function Subscription() {
</div> </div>
)} )}
{/* Reissue Subscription — standalone block, not dependent on device_limit */}
{subscription &&
(subscription.is_active || subscription.is_limited) &&
!subscription.is_trial && (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '16px 20px',
}}
>
<button
onClick={handleRevoke}
disabled={revokeMutation.isPending || revokeCooldown > 0}
className="w-full rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-left transition-colors hover:bg-amber-500/20 disabled:opacity-50"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-amber-400">
{t('subscription.revoke.button')}
</div>
<div className="mt-1 text-sm text-dark-400">
{revokeCooldown > 0
? t('subscription.revoke.cooldown', {
minutes: Math.floor(revokeCooldown / 60),
seconds: revokeCooldown % 60,
})
: t('subscription.revoke.description')}
</div>
</div>
<div className="text-amber-400">
{revokeMutation.isPending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" />
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"
/>
</svg>
)}
</div>
</div>
</button>
{revokeMutation.error && (
<p className="mt-2 text-sm text-red-400">{getErrorMessage(revokeMutation.error)}</p>
)}
</div>
)}
{/* My Devices Section */} {/* My Devices Section */}
{subscription && ( {subscription && (
<div <div