diff --git a/src/components/broadcasts/BroadcastPreview.tsx b/src/components/broadcasts/BroadcastPreview.tsx
new file mode 100644
index 0000000..91e3fbe
--- /dev/null
+++ b/src/components/broadcasts/BroadcastPreview.tsx
@@ -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+[^>]*)?>|
/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('
(i === 0 ? [p] : [
, 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 {frame.children};
+ case 'i':
+ case 'em':
+ return {frame.children};
+ case 'u':
+ case 'ins':
+ return {frame.children};
+ case 's':
+ case 'strike':
+ case 'del':
+ return {frame.children};
+ case 'code':
+ return (
+
+ {frame.children}
+
+ );
+ case 'pre':
+ return (
+
+ {frame.children}
+
+ );
+ case 'a': {
+ const safeHref =
+ frame.href && /^(https?:|tg:|mailto:|tel:)/i.test(frame.href) ? frame.href : '#';
+ return (
+
+ {frame.children}
+
+ );
+ }
+ case 'tg-spoiler':
+ case 'span':
+ return {frame.children};
+ default:
+ return {frame.children};
+ }
+}
+
+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({renderText(tok.value || '', textKey)});
+ } else if (tok.kind === 'br') {
+ top.children.push(
);
+ } 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(
+
+
e.stopPropagation()}
+ >
+
+
+ {t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
+
+
+
+
+
+ {mediaUrl && mediaType === 'photo' && (
+

+ )}
+ {mediaUrl && mediaType === 'video' && (
+
+ )}
+ {text ? (
+
+ {rendered}
+
+ ) : (
+
+ {t('admin.broadcasts.previewEmpty', '— пусто —')}
+
+ )}
+
+ {buttons && buttons.length > 0 && (
+
+ {buttons.map((row, ri) => (
+
+ {row.map((b, ci) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+
,
+ document.body,
+ );
+}
+
+export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) {
+ const { t } = useTranslation();
+ if (!open) return null;
+ const emptyHtml = `${t('admin.broadcasts.previewEmpty', '— пусто —')}
`;
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+ {t('admin.broadcasts.emailSubject', 'Тема')}
+
+
+ {subject || t('admin.broadcasts.previewEmpty', '— пусто —')}
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx
index de04726..c175cee 100644
--- a/src/pages/AdminBroadcastCreate.tsx
+++ b/src/pages/AdminBroadcastCreate.tsx
@@ -10,6 +10,7 @@ import {
CustomBroadcastButton,
} from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
+import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview';
// Icons
const BroadcastIcon = () => (
@@ -160,6 +161,42 @@ export default function AdminBroadcastCreate() {
// Submitting state for dual send
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 = {
+ 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
const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'],
@@ -623,9 +660,19 @@ export default function AdminBroadcastCreate() {
{/* Telegram section */}
{telegramEnabled && (
-
- {t('admin.broadcasts.telegramSection')}
-
+
+
+ {t('admin.broadcasts.telegramSection')}
+
+
+
{/* Telegram filter selection */}
{renderFilterDropdown(
@@ -861,9 +908,19 @@ export default function AdminBroadcastCreate() {
{/* Email section */}
{emailEnabled && (
-
- {t('admin.broadcasts.emailSection')}
-
+
+
+ {t('admin.broadcasts.emailSection')}
+
+
+
{/* Email filter selection */}
{renderFilterDropdown(
@@ -961,6 +1018,21 @@ export default function AdminBroadcastCreate() {
+
+ setShowTelegramPreview(false)}
+ text={messageText}
+ mediaUrl={mediaPreview}
+ mediaType={previewMediaTypeForModal}
+ buttons={previewButtonRows}
+ />
+ setShowEmailPreview(false)}
+ subject={emailSubject}
+ htmlContent={emailContent}
+ />
);
}