diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 2af14bf..832954d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -540,6 +540,17 @@ export const subscriptionApi = { 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 ────────────────────────────────────────────── togglePause: async ( diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index de01287..06d37b7 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -295,6 +295,175 @@ export default function PaymentMethodIcon({ ); } + case 'etoplatezhi': { + const etoplatezhiGradId = `${uid}-etoplatezhi`; + return ( + + + + + + + + + + + EP + + + + ); + } + + case 'antilopay': { + const antilopayGradId = `${uid}-antilopay`; + return ( + + + + + + + + + + + AL + + + + ); + } + + case 'jupiter': { + const jupiterGradId = `${uid}-jupiter`; + const jupiterRingId = `${uid}-jupiter-ring`; + return ( + + + + + + + + + + + + + + + + + ); + } + + case 'donut': { + const donutBgGradId = `${uid}-donut-bg`; + const donutGlazeGradId = `${uid}-donut-glaze`; + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + + case 'lava': { + const lavaBgGradId = `${uid}-lava-bg`; + const lavaFlowGradId = `${uid}-lava-flow`; + return ( + + + + + + + + + + + + + + + + + + + + ); + } + + case 'apple_iap': + return ( + + + + + ); + default: return ( diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index 2c99654..7a1b1a1 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -55,6 +55,12 @@ export const SETTINGS_TREE: SettingsTreeConfig = { { id: 'payments_paypear', categories: ['PAYPEAR'] }, { id: 'payments_rollypay', categories: ['ROLLYPAY'] }, { 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'] }, ], }, { 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' && ( +
+ {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', '— пусто —')} +
+
+ +
+