From 58887138fcbe22863cde83d8bf24079ba7925a56 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 06:01:20 +0300 Subject: [PATCH 01/10] feat: add Apple IAP (apple_iap) payment method support Add apple_iap across all payment surfaces: method label, icon (Apple logo SVG), admin settings tree (APPLE_IAP category), and all four locale files (en, ru, fa, zh) with payment description + tree label. --- src/components/PaymentMethodIcon.tsx | 11 +++++++++++ src/components/admin/constants.ts | 1 + src/constants/paymentMethods.ts | 1 + src/locales/en.json | 4 ++++ src/locales/fa.json | 4 ++++ src/locales/ru.json | 4 ++++ src/locales/zh.json | 4 ++++ 7 files changed, 29 insertions(+) diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index de01287..fafce74 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -295,6 +295,17 @@ export default function PaymentMethodIcon({ ); } + case 'apple_iap': + return ( + + + + + ); + default: return ( diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index 2c99654..3f25803 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -55,6 +55,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = { { id: 'payments_paypear', categories: ['PAYPEAR'] }, { id: 'payments_rollypay', categories: ['ROLLYPAY'] }, { id: 'payments_aurapay', categories: ['AURAPAY'] }, + { id: 'payments_apple_iap', categories: ['APPLE_IAP'] }, ], }, { diff --git a/src/constants/paymentMethods.ts b/src/constants/paymentMethods.ts index 81657c1..215f8ec 100644 --- a/src/constants/paymentMethods.ts +++ b/src/constants/paymentMethods.ts @@ -18,5 +18,6 @@ export const METHOD_LABELS: Record = { paypear: 'PayPear', rollypay: 'RollyPay', aurapay: 'AuraPay', + apple_iap: 'Apple In-App Purchase', manual: 'Ручное пополнение', }; diff --git a/src/locales/en.json b/src/locales/en.json index 607ddcc..b03b81e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -659,6 +659,9 @@ }, "aurapay": { "description": "Pay via AuraPay" + }, + "apple_iap": { + "description": "Pay via Apple In-App Purchase" } }, "transactionHistory": "Transaction History", @@ -1971,6 +1974,7 @@ "payments_paypear": "PayPear", "payments_rollypay": "RollyPay", "payments_aurapay": "AuraPay", + "payments_apple_iap": "Apple IAP", "subs_core": "Core", "subs_trial": "Trial", "subs_pricing": "Pricing", diff --git a/src/locales/fa.json b/src/locales/fa.json index 4bdc6ee..6d0752d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -496,6 +496,9 @@ }, "severpay": { "description": "پرداخت از طریق SeverPay" + }, + "apple_iap": { + "description": "پرداخت از طریق Apple In-App Purchase" } }, "transactionHistory": "تاریخچه تراکنش‌ها", @@ -1621,6 +1624,7 @@ "payments_paypear": "PayPear", "payments_rollypay": "RollyPay", "payments_aurapay": "AuraPay", + "payments_apple_iap": "Apple IAP", "subs_core": "هسته", "subs_trial": "آزمایشی", "subs_pricing": "قیمت‌گذاری", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7c03a0f..ef2b06c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -687,6 +687,9 @@ }, "aurapay": { "description": "Оплата через AuraPay" + }, + "apple_iap": { + "description": "Оплата через Apple In-App Purchase" } }, "transactionHistory": "История операций", @@ -1987,6 +1990,7 @@ "payments_paypear": "PayPear", "payments_rollypay": "RollyPay", "payments_aurapay": "AuraPay", + "payments_apple_iap": "Apple IAP", "subs_core": "Основные", "subs_trial": "Пробный период", "subs_pricing": "Цены", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9993ce9..1571e62 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -496,6 +496,9 @@ }, "severpay": { "description": "通过SeverPay支付" + }, + "apple_iap": { + "description": "通过Apple应用内购买支付" } }, "transactionHistory": "交易记录", @@ -1659,6 +1662,7 @@ "payments_paypear": "PayPear", "payments_rollypay": "RollyPay", "payments_aurapay": "AuraPay", + "payments_apple_iap": "Apple IAP", "subs_core": "核心", "subs_trial": "试用", "subs_pricing": "定价", From d21c6637bf3a26fe7a6a7822eadc93b0de18de83 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 06:18:12 +0300 Subject: [PATCH 02/10] feat(admin/broadcasts): add preview buttons for Telegram + Email broadcasts Add BroadcastPreview component with local rendering of Telegram messages (HTML tokenizer with tag whitelist, no dangerouslySetInnerHTML) and Email preview (sandboxed iframe). Preview buttons appear in broadcast create page section headers, disabled when content is empty. Based on PR #416 by @smediainfo. --- .../broadcasts/BroadcastPreview.tsx | 287 ++++++++++++++++++ src/pages/AdminBroadcastCreate.tsx | 84 ++++- 2 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 src/components/broadcasts/BroadcastPreview.tsx 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', '— пусто —')} +
+
+ +
+