mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
287
src/components/broadcasts/BroadcastPreview.tsx
Normal file
287
src/components/broadcasts/BroadcastPreview.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
354
src/components/connection/TvQuickConnect.tsx
Normal file
354
src/components/connection/TvQuickConnect.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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: 'Ручное пополнение',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "قیمتگذاری",
|
||||||
|
|||||||
@@ -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": "Цены",
|
||||||
|
|||||||
@@ -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": "定价",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
{t('admin.broadcasts.telegramSection')}
|
{t('admin.broadcasts.telegramSection')}
|
||||||
</h2>
|
</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">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
{t('admin.broadcasts.emailSection')}
|
{t('admin.broadcasts.emailSection')}
|
||||||
</h2>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user