From 2490399f8eb8a96ea0992c134f4a33c6001c885e Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 00:48:56 +0300 Subject: [PATCH 01/23] feat: move user action buttons to detail page and fix full delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fullDeleteUser API method calling DELETE /admin/users/{id}/full - Add Reset trial, Reset subscription, Disable, Delete buttons to AdminUserDetail info tab - Implement inline confirm pattern (click → "Are you sure?" → execute, 3s timeout) - Delete now calls /full endpoint removing user from bot DB and Remnawave panel - Remove UserActionsMenu dropdown, ConfirmationModal and related code from AdminUsers list - Update delete confirmation text in ru/en locales to reflect full deletion --- src/api/adminUsers.ts | 16 +- src/locales/en.json | 6 +- src/locales/ru.json | 6 +- src/pages/AdminUserDetail.tsx | 153 +++++++++++++++- src/pages/AdminUsers.tsx | 320 +--------------------------------- 5 files changed, 180 insertions(+), 321 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 2ff043b..c4a3d3d 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -415,7 +415,7 @@ export const adminUsersApi = { return response.data; }, - // Delete user + // Delete user (soft delete, does NOT remove from Remnawave) deleteUser: async ( userId: number, softDelete = true, @@ -427,6 +427,20 @@ export const adminUsersApi = { return response.data; }, + // Full delete user (removes from bot DB + Remnawave panel) + fullDeleteUser: async ( + userId: number, + ): Promise<{ + success: boolean; + message: string; + deleted_from_bot: boolean; + deleted_from_panel: boolean; + panel_error: string | null; + }> => { + const response = await apiClient.delete(`/cabinet/admin/users/${userId}/full`); + return response.data; + }, + // Get referrals getReferrals: async (userId: number, offset = 0, limit = 50): Promise => { const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { diff --git a/src/locales/en.json b/src/locales/en.json index 96b5497..77acc04 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1808,7 +1808,7 @@ }, "confirmDelete": { "title": "Delete user?", - "message": "Are you sure? This action is irreversible. All user data will be deleted." + "message": "Are you sure? This action is irreversible. The user will be fully deleted from the bot and from the Remnawave panel." }, "confirmResetTrial": { "title": "Reset trial?", @@ -1848,6 +1848,10 @@ "subscription": "• Subscription purchase prohibited", "reason": "Reason" }, + "actions": { + "title": "Actions", + "areYouSure": "Are you sure?" + }, "subscription": { "current": "Current subscription", "tariff": "Tariff", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7585644..c6ef15f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2335,7 +2335,7 @@ }, "confirmDelete": { "title": "Удалить пользователя?", - "message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены." + "message": "Вы уверены? Это действие необратимо. Пользователь будет полностью удалён из бота и из панели Remnawave." }, "confirmResetTrial": { "title": "Сбросить триал?", @@ -2375,6 +2375,10 @@ "subscription": "• Запрет покупки подписки", "reason": "Причина" }, + "actions": { + "title": "Действия", + "areYouSure": "Уверены?" + }, "subscription": { "current": "Текущая подписка", "tariff": "Тариф", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 40c2c67..5a0c6c0 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; import { useCurrency } from '../hooks/useCurrency'; +import { useNotify } from '../platform/hooks/useNotify'; import { adminUsersApi, type UserDetailResponse, @@ -80,6 +81,7 @@ export default function AdminUserDetail() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); + const notify = useNotify(); const { id } = useParams<{ id: string }>(); const localeMap: Record = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' }; @@ -92,6 +94,10 @@ export default function AdminUserDetail() { const [tariffs, setTariffs] = useState([]); const [actionLoading, setActionLoading] = useState(false); + // Inline confirm state + const [confirmingAction, setConfirmingAction] = useState(null); + const confirmTimerRef = useRef | null>(null); + // Balance form const [balanceAmount, setBalanceAmount] = useState(''); const [balanceDescription, setBalanceDescription] = useState(''); @@ -256,6 +262,90 @@ export default function AdminUserDetail() { } }; + const handleInlineConfirm = (actionKey: string, executeFn: () => Promise) => { + if (confirmingAction === actionKey) { + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + setConfirmingAction(null); + executeFn(); + } else { + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + setConfirmingAction(actionKey); + confirmTimerRef.current = setTimeout(() => setConfirmingAction(null), 3000); + } + }; + + const handleResetTrial = async () => { + if (!userId) return; + setActionLoading(true); + try { + const result = await adminUsersApi.resetTrial(userId); + if (result.success) { + notify.success(t('admin.users.userActions.success.resetTrial'), t('common.success')); + await loadUser(); + } else { + notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); + } + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleResetSubscription = async () => { + if (!userId) return; + setActionLoading(true); + try { + const result = await adminUsersApi.resetSubscription(userId); + if (result.success) { + notify.success(t('admin.users.userActions.success.resetSubscription'), t('common.success')); + await loadUser(); + } else { + notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); + } + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleDisableUser = async () => { + if (!userId) return; + setActionLoading(true); + try { + const result = await adminUsersApi.disableUser(userId); + if (result.success) { + notify.success(t('admin.users.userActions.success.disable'), t('common.success')); + await loadUser(); + } else { + notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); + } + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleFullDeleteUser = async () => { + if (!userId) return; + setActionLoading(true); + try { + const result = await adminUsersApi.fullDeleteUser(userId); + if (result.success) { + notify.success(t('admin.users.userActions.success.delete'), t('common.success')); + navigate('/admin/users'); + } else { + notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); + } + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const formatDate = (date: string | null) => { if (!date) return '-'; return new Date(date).toLocaleDateString(locale, { @@ -459,6 +549,67 @@ export default function AdminUserDetail() { )} )} + + {/* Actions */} +
+
+ {t('admin.users.detail.actions.title')} +
+
+ + + + +
+
)} diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index 1831f93..ef01046 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -1,10 +1,9 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useCurrency } from '../hooks/useCurrency'; import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers'; import { usePlatform } from '../platform/hooks/usePlatform'; -import { useNotify } from '../platform/hooks/useNotify'; // ============ Icons ============ @@ -58,204 +57,6 @@ const TelegramIcon = () => ( ); -const DotsVerticalIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ArrowPathIcon = () => ( - - - -); - -const NoSymbolIcon = () => ( - - - -); - -const ExclamationTriangleIcon = () => ( - - - -); - -// ============ Confirmation Modal ============ - -interface ConfirmationModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title: string; - message: string; - confirmText: string; - confirmButtonClass?: string; - isLoading?: boolean; -} - -function ConfirmationModal({ - isOpen, - onClose, - onConfirm, - title, - message, - confirmText, - confirmButtonClass = 'bg-rose-500 hover:bg-rose-600', - isLoading = false, -}: ConfirmationModalProps) { - const { t } = useTranslation(); - - if (!isOpen) return null; - - return ( -
-
-
-
-
- -
-

{title}

-
-

{message}

-
- - -
-
-
- ); -} - -// ============ User Actions Menu ============ - -type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable'; - -interface UserActionsMenuProps { - user: UserListItem; - onAction: (action: UserAction, user: UserListItem) => void; -} - -function UserActionsMenu({ user, onAction }: UserActionsMenuProps) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const handleAction = (action: UserAction) => { - setIsOpen(false); - onAction(action, user); - }; - - const actions = [ - { - key: 'resetTrial' as const, - label: t('admin.users.userActions.resetTrial'), - icon: , - className: 'text-blue-400 hover:bg-blue-500/10', - }, - { - key: 'resetSubscription' as const, - label: t('admin.users.userActions.resetSubscription'), - icon: , - className: 'text-amber-400 hover:bg-amber-500/10', - }, - { - key: 'disable' as const, - label: t('admin.users.userActions.disable'), - icon: , - className: 'text-dark-400 hover:bg-dark-700', - }, - { - key: 'delete' as const, - label: t('admin.users.userActions.delete'), - icon: , - className: 'text-rose-400 hover:bg-rose-500/10', - }, - ]; - - return ( -
- - - {isOpen && ( -
- {actions.map((action) => ( - - ))} -
- )} -
- ); -} - // ============ Components ============ interface StatCardProps { @@ -305,11 +106,10 @@ function StatusBadge({ status }: { status: string }) { interface UserRowProps { user: UserListItem; onClick: () => void; - onAction: (action: UserAction, user: UserListItem) => void; formatAmount: (rubAmount: number) => string; } -function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) { +function UserRow({ user, onClick, formatAmount }: UserRowProps) { const { t } = useTranslation(); return (
- {/* Actions Menu - hide chevron on mobile, show only dots */} - - -
- -
+
); } // ============ Main Page ============ -interface ConfirmModalState { - isOpen: boolean; - action: UserAction | null; - user: UserListItem | null; -} - export default function AdminUsers() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); - const notify = useNotify(); const { capabilities } = usePlatform(); const [users, setUsers] = useState([]); @@ -406,12 +194,6 @@ export default function AdminUsers() { const [sortBy, setSortBy] = useState('created_at'); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); - const [confirmModal, setConfirmModal] = useState({ - isOpen: false, - action: null, - user: null, - }); - const [actionLoading, setActionLoading] = useState(false); const limit = 20; @@ -458,92 +240,6 @@ export default function AdminUsers() { loadUsers(); }; - const handleUserAction = (action: UserAction, user: UserListItem) => { - setConfirmModal({ isOpen: true, action, user }); - }; - - const closeConfirmModal = () => { - setConfirmModal({ isOpen: false, action: null, user: null }); - }; - - const executeAction = async () => { - const { action, user } = confirmModal; - if (!action || !user) return; - - setActionLoading(true); - try { - let result: { success: boolean; message: string }; - - switch (action) { - case 'delete': - result = await adminUsersApi.deleteUser(user.id); - break; - case 'resetTrial': - result = await adminUsersApi.resetTrial(user.id); - break; - case 'resetSubscription': - result = await adminUsersApi.resetSubscription(user.id); - break; - case 'disable': - result = await adminUsersApi.disableUser(user.id); - break; - default: - throw new Error('Unknown action'); - } - - if (result.success) { - notify.success(t(`admin.users.userActions.success.${action}`), t('common.success')); - loadUsers(); - loadStats(); - } else { - notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); - } - } catch (error) { - console.error('Action failed:', error); - notify.error(t('admin.users.userActions.error'), t('common.error')); - } finally { - setActionLoading(false); - closeConfirmModal(); - } - }; - - const getConfirmModalContent = () => { - const { action } = confirmModal; - if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' }; - - const configs: Record< - UserAction, - { title: string; message: string; confirmText: string; confirmButtonClass: string } - > = { - delete: { - title: t('admin.users.userActions.confirmDelete.title'), - message: t('admin.users.userActions.confirmDelete.message'), - confirmText: t('admin.users.userActions.delete'), - confirmButtonClass: 'bg-rose-500 hover:bg-rose-600', - }, - resetTrial: { - title: t('admin.users.userActions.confirmResetTrial.title'), - message: t('admin.users.userActions.confirmResetTrial.message'), - confirmText: t('admin.users.userActions.resetTrial'), - confirmButtonClass: 'bg-blue-500 hover:bg-blue-600', - }, - resetSubscription: { - title: t('admin.users.userActions.confirmResetSubscription.title'), - message: t('admin.users.userActions.confirmResetSubscription.message'), - confirmText: t('admin.users.userActions.resetSubscription'), - confirmButtonClass: 'bg-amber-500 hover:bg-amber-600', - }, - disable: { - title: t('admin.users.userActions.confirmDisable.title'), - message: t('admin.users.userActions.confirmDisable.message'), - confirmText: t('admin.users.userActions.disable'), - confirmButtonClass: 'bg-dark-600 hover:bg-dark-500', - }, - }; - - return configs[action]; - }; - const totalPages = Math.ceil(total / limit); const currentPage = Math.floor(offset / limit) + 1; @@ -682,7 +378,6 @@ export default function AdminUsers() { key={user.id} user={user} onClick={() => navigate(`/admin/users/${user.id}`)} - onAction={handleUserAction} formatAmount={(amount) => formatWithCurrency(amount)} /> )) @@ -720,15 +415,6 @@ export default function AdminUsers() { )} - - {/* User action confirmation modal */} - ); } From 7c304545f8fcef0a2d1d589255d363bd35fe877d Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 01:01:08 +0300 Subject: [PATCH 02/23] fix: move theme save/cancel buttons outside collapsible section Save and Cancel buttons were hidden inside the collapsible "Custom Colors" section, so manual color changes were lost on navigation. Moved them to the top level so they are always visible when there are unsaved changes. --- src/components/admin/ThemeTab.tsx | 46 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index da9f2f3..7938473 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -395,34 +395,32 @@ export function ThemeTab() { /> - - {/* Save / Cancel / Reset buttons */} -
- {hasUnsavedChanges && ( - <> - - - - )} -
)} + {/* Save / Cancel — always visible when there are unsaved changes */} + {hasUnsavedChanges && ( +
+ + +
+ )} + {/* Reset all colors */}
+ {/* OAuth providers section */} + {oauthProviders.length > 0 && ( + <> +
+
+ {t('auth.or', 'or')} +
+
+
+ {oauthProviders.map((provider) => ( + + ))} +
+ + )} + {/* Email auth section - only when enabled */} {isEmailAuthEnabled && ( <> diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx new file mode 100644 index 0000000..7078fc5 --- /dev/null +++ b/src/pages/OAuthCallback.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/auth'; + +// SessionStorage helpers for OAuth state +const OAUTH_STATE_KEY = 'oauth_state'; +const OAUTH_PROVIDER_KEY = 'oauth_provider'; + +export function saveOAuthState(state: string, provider: string): void { + sessionStorage.setItem(OAUTH_STATE_KEY, state); + sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider); +} + +export function getAndClearOAuthState(): { state: string; provider: string } | null { + const state = sessionStorage.getItem(OAUTH_STATE_KEY); + const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY); + sessionStorage.removeItem(OAUTH_STATE_KEY); + sessionStorage.removeItem(OAUTH_PROVIDER_KEY); + if (!state || !provider) return null; + return { state, provider }; +} + +export default function OAuthCallback() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [error, setError] = useState(''); + const { loginWithOAuth, isAuthenticated } = useAuthStore(); + + useEffect(() => { + if (isAuthenticated) { + navigate('/', { replace: true }); + return; + } + + const authenticate = async () => { + const code = searchParams.get('code'); + const urlState = searchParams.get('state'); + + if (!code || !urlState) { + setError(t('auth.oauthError', 'Authorization was denied or failed')); + return; + } + + // Get saved state from sessionStorage + const saved = getAndClearOAuthState(); + if (!saved) { + setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.')); + return; + } + + // Validate state match + if (saved.state !== urlState) { + setError(t('auth.oauthError', 'Authorization was denied or failed')); + return; + } + + try { + await loginWithOAuth(saved.provider, code, urlState); + navigate('/', { replace: true }); + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } }; + setError( + error.response?.data?.detail || + t('auth.oauthError', 'Authorization was denied or failed'), + ); + } + }; + + authenticate(); + }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]); + + if (error) { + return ( +
+
+
+
+
+ + + +
+

{t('auth.loginFailed')}

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+
+

{t('auth.authenticating')}

+

{t('common.loading')}

+
+
+ ); +} diff --git a/src/store/auth.ts b/src/store/auth.ts index 91b2fd0..56364bb 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -33,6 +33,7 @@ interface AuthState { loginWithTelegram: (initData: string) => Promise; loginWithTelegramWidget: (data: TelegramWidgetData) => Promise; loginWithEmail: (email: string, password: string) => Promise; + loginWithOAuth: (provider: string, code: string, state: string) => Promise; registerWithEmail: ( email: string, password: string, @@ -267,6 +268,18 @@ export const useAuthStore = create()( await get().checkAdminStatus(); }, + loginWithOAuth: async (provider, code, state) => { + const response = await authApi.oauthCallback(provider, code, state); + tokenStorage.setTokens(response.access_token, response.refresh_token); + set({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + user: response.user, + isAuthenticated: true, + }); + await get().checkAdminStatus(); + }, + registerWithEmail: async (email, password, firstName, referralCode) => { // Registration now returns message, not tokens // User must verify email before they can login diff --git a/src/types/index.ts b/src/types/index.ts index 4d45348..23bc36e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,13 @@ export interface User { referral_code: string | null; language: string; created_at: string; - auth_type: 'telegram' | 'email'; // Тип аутентификации + auth_type: 'telegram' | 'email' | 'google' | 'yandex' | 'discord' | 'vk'; // Тип аутентификации +} + +// OAuth types +export interface OAuthProvider { + name: string; + display_name: string; } // Auth types From c4f228fba6cbb0fe9ce0ac007e05c0cf2bf1fff0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 03:36:25 +0300 Subject: [PATCH 06/23] feat: add SVG brand icons for payment methods Replace emoji icons with proper SVG brand-colored icons for all 12 payment providers (YooKassa, CloudPayments, CryptoBot, Telegram Stars, Heleket, MulenPay, PAL24, Platega, WATA, Freekassa, Tribute, Kassa AI). --- src/components/PaymentMethodIcon.tsx | 170 +++++++++++++++++++++++++++ src/pages/AdminPaymentMethodEdit.tsx | 21 +--- src/pages/AdminPaymentMethods.tsx | 27 +---- src/pages/TopUpMethodSelect.tsx | 8 +- 4 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 src/components/PaymentMethodIcon.tsx diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx new file mode 100644 index 0000000..3f4aa1b --- /dev/null +++ b/src/components/PaymentMethodIcon.tsx @@ -0,0 +1,170 @@ +interface PaymentMethodIconProps { + method: string; + className?: string; +} + +export default function PaymentMethodIcon({ + method, + className = 'h-8 w-8', +}: PaymentMethodIconProps) { + switch (method) { + case 'telegram_stars': + return ( + + + + + ); + + case 'cryptobot': + return ( + + + + + ); + + case 'yookassa': + return ( + + + + + + + + ); + + case 'heleket': + return ( + + + + + ); + + case 'mulenpay': + return ( + + + + + ); + + case 'pal24': + return ( + + + + + P24 + + + + ); + + case 'platega': + return ( + + + + + ); + + case 'wata': + return ( + + + + + + ); + + case 'freekassa': + return ( + + + + + FK + + + + ); + + case 'cloudpayments': + return ( + + + + + ); + + case 'tribute': + return ( + + + + + ); + + case 'kassa_ai': + return ( + + + + + + + + + + + AI + + + + ); + + default: + return ( + + + + + ); + } +} diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index b1e3622..29a9fdd 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -6,6 +6,7 @@ import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import type { PromoGroupSimple } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; +import PaymentMethodIcon from '@/components/PaymentMethodIcon'; const BackIcon = () => ( ( ); -const METHOD_ICONS: Record = { - telegram_stars: '⭐', - tribute: '🎁', - cryptobot: '🪙', - heleket: '⚡', - yookassa: '🏦', - mulenpay: '💳', - pal24: '💸', - platega: '💰', - wata: '💧', - freekassa: '💵', - cloudpayments: '☁️', - kassa_ai: '🏦', -}; - const METHOD_LABELS: Record = { telegram_stars: 'Telegram Stars', tribute: 'Tribute', @@ -195,7 +181,6 @@ export default function AdminPaymentMethodEdit() { } const displayName = config.display_name || config.default_display_name; - const icon = METHOD_ICONS[config.method_id] || '💳'; return (
@@ -210,8 +195,8 @@ export default function AdminPaymentMethodEdit() { )} -
- {icon} +
+

{displayName}

diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx index 0994386..1b129fb 100644 --- a/src/pages/AdminPaymentMethods.tsx +++ b/src/pages/AdminPaymentMethods.tsx @@ -23,6 +23,7 @@ import { import { CSS } from '@dnd-kit/utilities'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import type { PaymentMethodConfig } from '../types'; +import PaymentMethodIcon from '@/components/PaymentMethodIcon'; // ============ Icons ============ @@ -64,23 +65,6 @@ const SaveIcon = () => ( ); -// ============ Method icon by type ============ - -const METHOD_ICONS: Record = { - telegram_stars: '\u2B50', - tribute: '\uD83C\uDF81', - cryptobot: '\uD83E\uDE99', - heleket: '\u26A1', - yookassa: '\uD83C\uDFE6', - mulenpay: '\uD83D\uDCB3', - pal24: '\uD83D\uDCB8', - platega: '\uD83D\uDCB0', - wata: '\uD83D\uDCA7', - freekassa: '\uD83D\uDCB5', - cloudpayments: '\u2601\uFE0F', - kassa_ai: '\uD83C\uDFE6', -}; - // ============ Sortable Card ============ interface SortableCardProps { @@ -102,7 +86,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) { }; const displayName = config.display_name || config.default_display_name; - const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'; // Build condition summary chips const chips: string[] = []; @@ -153,8 +136,8 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) { {/* Method icon */} -
- {icon} +
+
{/* Content */} @@ -337,8 +320,8 @@ export default function AdminPaymentMethods() { ) : (
-
- {'\uD83D\uDCB3'} +
+
{t('admin.paymentMethods.noMethods')}
diff --git a/src/pages/TopUpMethodSelect.tsx b/src/pages/TopUpMethodSelect.tsx index 1801e6a..b5fa841 100644 --- a/src/pages/TopUpMethodSelect.tsx +++ b/src/pages/TopUpMethodSelect.tsx @@ -7,6 +7,7 @@ import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; import { Card } from '@/components/data-display/Card'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; +import PaymentMethodIcon from '@/components/PaymentMethodIcon'; export default function TopUpMethodSelect() { const { t } = useTranslation(); @@ -70,8 +71,11 @@ export default function TopUpMethodSelect() { className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''} onClick={() => method.is_available && handleMethodClick(method.id)} > -
- {translatedName || method.name} +
+ +
+ {translatedName || method.name} +
{(translatedDesc || method.description) && (
From 2c0d265ff5c3ea9e3ed56fdb24cdd2301abba617 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 03:36:31 +0300 Subject: [PATCH 07/23] fix: remove incorrect ruble top-up prompt from fortune wheel Trial users saw an "insufficient balance" prompt suggesting to top up rubles, but the wheel only accepts Telegram Stars (via invoice) or subscription days. Stars payment via invoice doesn't require ruble balance, so the spin button should not be disabled for it. --- src/pages/Wheel.tsx | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 6a2cca0..1bb03f1 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'; import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'; import FortuneWheel from '../components/wheel/FortuneWheel'; import WheelLegend from '../components/wheel/WheelLegend'; -import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { usePlatform, useHaptic } from '@/platform'; import { useNotify } from '@/platform/hooks/useNotify'; import { Card } from '@/components/data-display/Card/Card'; @@ -461,11 +460,13 @@ export default function Wheel() { const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; const bothMethodsAvailable = !!(starsEnabled && daysEnabled); + // Stars via Telegram invoice don't require ruble balance, so only check daily limit + const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit; const spinDisabled = - !config.can_spin || isSpinning || isPayingStars || - (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit); + dailyLimitReached || + (paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin); return (
@@ -570,25 +571,21 @@ export default function Wheel() { )} - {/* Cannot spin hint */} - {!config.can_spin && !isSpinning && ( - <> - {config.can_spin_reason === 'daily_limit_reached' ? ( -
-

{t('wheel.errors.dailyLimitReached')}

-
- ) : config.can_spin_reason === 'insufficient_balance' ? ( - - ) : ( -
-

{t('wheel.errors.cannotSpin')}

-
- )} - + {/* Cannot spin hint — only show for days payment (Stars via invoice always works) */} + {!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && ( +
+

+ {config.can_spin_reason === 'daily_limit_reached' + ? t('wheel.errors.dailyLimitReached') + : t('wheel.errors.cannotSpin')} +

+
+ )} + {/* Daily limit hint for Stars payment (not covered by can_spin check) */} + {!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && ( +
+

{t('wheel.errors.dailyLimitReached')}

+
)} {/* Inline Result Card */} From ab80e311b56e4e1fc1b4eca851b52db3af28f79c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 03:45:17 +0300 Subject: [PATCH 08/23] fix: theme custom colors save button not appearing savedColorsRef was unconditionally updated in the serverColors sync effect, including when queryClient.setQueryData was called from updateDraftColor for live preview. This made hasUnsavedChanges always false, hiding the save/cancel buttons. Move savedColorsRef update inside the sync condition so it only updates when no local edits exist. --- src/components/admin/ThemeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index 7938473..3bfad07 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -85,8 +85,8 @@ export function ThemeTab() { colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS) ) { setDraftColors(colors); + savedColorsRef.current = colors; } - savedColorsRef.current = colors; } }, [serverColors]); From 33e878da846409868f623b36532b7d73a1a678d0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 04:14:35 +0300 Subject: [PATCH 09/23] feat: update payment method icons with brand-accurate favicon designs Replace generic placeholder icons with designs based on actual favicon assets from payment provider websites. Updated icons for Platega, Tribute, WATA, CloudPayments, Kassa AI, YooKassa, CryptoBot, Heleket, MulenPay, PAL24, and Freekassa with correct brand colors and marks. --- src/components/PaymentMethodIcon.tsx | 122 +++++++++++++++------------ 1 file changed, 70 insertions(+), 52 deletions(-) diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 3f4aa1b..82da357 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -8,6 +8,7 @@ export default function PaymentMethodIcon({ className = 'h-8 w-8', }: PaymentMethodIconProps) { switch (method) { + // Telegram Stars — blue circle + gold star case 'telegram_stars': return ( @@ -19,139 +20,156 @@ export default function PaymentMethodIcon({ ); + // CryptoBot (app.cr.bot) — blue circle, white BTC-style ₿ mark case 'cryptobot': return ( ); + // YooKassa — blue circle, stylized "Ю" mark (vertical bar + ring) case 'yookassa': return ( - - - - + + ); + // Heleket — dark circle, green H-mark (from favicon.ico brand) case 'heleket': return ( - + ); + // MulenPay — red circle, white "M" letter mark case 'mulenpay': return ( - - + + ); + // PAL24 / PayPalych (pally.info) — green circle, white "P" mark case 'pal24': return ( - - - - P24 - - + + ); + // Platega — purple/blue gradient mark from favicon (triangle P shape) case 'platega': return ( - + + + + + + + + + ); + // WATA (wata.pro) — yellow-green square icon with black W/V shapes from favicon case 'wata': return ( - + - ); + // Freekassa — orange circle, white "F" mark case 'freekassa': return ( - - - FK - - + ); + // CloudPayments — blue circle with white checkmark cross from favicon.svg case 'cloudpayments': return ( - + + ); + // Tribute — blue gradient rounded square with white star/arrow from favicon case 'tribute': return ( - + + + + + + + + ); + // Kassa AI — orange circle with white "K" mark from brand case 'kassa_ai': return ( - - - - - - - - - - AI - - + + ); From f8b74f3cfbbf883b073d6af086629759ae5df243 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 04:41:07 +0300 Subject: [PATCH 10/23] Revert "Merge pull request #166 from BEDOLAGA-DEV/feat/payment-icons-from-favicons" This reverts commit e24afc4b6f9b5d9048c8af2d0e427f7e5916cd0c, reversing changes made to 017a6fae35a395234ed6dcbd546e11cc7d38d455. --- src/components/PaymentMethodIcon.tsx | 122 ++++++++++++--------------- 1 file changed, 52 insertions(+), 70 deletions(-) diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 82da357..3f4aa1b 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -8,7 +8,6 @@ export default function PaymentMethodIcon({ className = 'h-8 w-8', }: PaymentMethodIconProps) { switch (method) { - // Telegram Stars — blue circle + gold star case 'telegram_stars': return ( @@ -20,156 +19,139 @@ export default function PaymentMethodIcon({ ); - // CryptoBot (app.cr.bot) — blue circle, white BTC-style ₿ mark case 'cryptobot': return ( ); - // YooKassa — blue circle, stylized "Ю" mark (vertical bar + ring) case 'yookassa': return ( - - + + + + ); - // Heleket — dark circle, green H-mark (from favicon.ico brand) case 'heleket': return ( - + ); - // MulenPay — red circle, white "M" letter mark case 'mulenpay': return ( - - + + ); - // PAL24 / PayPalych (pally.info) — green circle, white "P" mark case 'pal24': return ( - - + + + + P24 + + ); - // Platega — purple/blue gradient mark from favicon (triangle P shape) case 'platega': return ( - - + - - - - - - - ); - // WATA (wata.pro) — yellow-green square icon with black W/V shapes from favicon case 'wata': return ( - + + ); - // Freekassa — orange circle, white "F" mark case 'freekassa': return ( - + + + FK + + ); - // CloudPayments — blue circle with white checkmark cross from favicon.svg case 'cloudpayments': return ( - + - ); - // Tribute — blue gradient rounded square with white star/arrow from favicon case 'tribute': return ( - + - - - - - - - ); - // Kassa AI — orange circle with white "K" mark from brand case 'kassa_ai': return ( - - + + + + + + + + + + AI + + ); From 772dcf72365581be587456cd1f7e35c969b7c898 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 04:59:02 +0300 Subject: [PATCH 11/23] feat: dual-channel broadcast form (Telegram + Email simultaneously) Replace single channel radio with independent toggles allowing both channels to be enabled at once with separate audiences and content. When both enabled, sends two sequential API requests. --- src/locales/en.json | 7 +- src/locales/fa.json | 7 +- src/locales/ru.json | 7 +- src/locales/zh.json | 7 +- src/pages/AdminBroadcastCreate.tsx | 704 ++++++++++++++++------------- 5 files changed, 421 insertions(+), 311 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index aad704e..4ca39d0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1099,7 +1099,12 @@ "emailContent": "Email content", "emailContentHint": "HTML supported", "emailContentPlaceholder": "

Hello, {{user_name}}!

\n

Your message here...

", - "emailVariables": "Available variables" + "emailVariables": "Available variables", + "enableTelegram": "Telegram", + "enableEmail": "Email", + "sendingBoth": "2 broadcasts will be created", + "bothCreated": "Broadcasts created", + "atLeastOneChannel": "Select at least one channel" }, "settings": { "title": "System Settings", diff --git a/src/locales/fa.json b/src/locales/fa.json index d315c08..6dc458d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -803,7 +803,12 @@ "activity": "بر اساس فعالیت", "source": "بر اساس منبع", "tariff": "بر اساس تعرفه" - } + }, + "enableTelegram": "Telegram", + "enableEmail": "Email", + "sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد", + "bothCreated": "پیام‌رسانی‌ها ایجاد شدند", + "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید" }, "payments": { "title": "تأیید پرداخت", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2715923..c7faef0 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1124,7 +1124,12 @@ "emailContent": "Содержимое письма", "emailContentHint": "Поддерживается HTML", "emailContentPlaceholder": "

Здравствуйте, {{user_name}}!

\n

Текст вашего письма...

", - "emailVariables": "Доступные переменные" + "emailVariables": "Доступные переменные", + "enableTelegram": "Telegram", + "enableEmail": "Email", + "sendingBoth": "Будет создано 2 рассылки", + "bothCreated": "Рассылки созданы", + "atLeastOneChannel": "Выберите хотя бы один канал" }, "settings": { "title": "Настройки системы", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5b9d533..b6435e6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -855,7 +855,12 @@ "activity": "按活动", "source": "按来源", "tariff": "按套餐" - } + }, + "enableTelegram": "Telegram", + "enableEmail": "Email", + "sendingBoth": "将创建2个群发", + "bothCreated": "群发已创建", + "atLeastOneChannel": "请至少选择一个渠道" }, "settings": { "title": "系统设置", diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 3ee1dea..6e514f8 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -6,7 +6,6 @@ import { adminBroadcastsApi, BroadcastFilter, TariffFilter, - BroadcastChannel, CombinedBroadcastCreateRequest, } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; @@ -118,12 +117,15 @@ export default function AdminBroadcastCreate() { const queryClient = useQueryClient(); const fileInputRef = useRef(null); - // Channel selection - const [channel, setChannel] = useState('telegram'); + // Channel toggles (both can be enabled) + const [telegramEnabled, setTelegramEnabled] = useState(true); + const [emailEnabled, setEmailEnabled] = useState(false); - // Common state - const [target, setTarget] = useState(''); - const [showFilters, setShowFilters] = useState(false); + // Separate targets per channel + const [telegramTarget, setTelegramTarget] = useState(''); + const [emailTarget, setEmailTarget] = useState(''); + const [showTelegramFilters, setShowTelegramFilters] = useState(false); + const [showEmailFilters, setShowEmailFilters] = useState(false); // Telegram-specific state const [messageText, setMessageText] = useState(''); @@ -138,29 +140,32 @@ export default function AdminBroadcastCreate() { const [emailSubject, setEmailSubject] = useState(''); const [emailContent, setEmailContent] = useState(''); + // Submitting state for dual send + const [isSubmitting, setIsSubmitting] = useState(false); + // Fetch Telegram filters const { data: filtersData, isLoading: filtersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'filters'], queryFn: adminBroadcastsApi.getFilters, - enabled: channel === 'telegram', + enabled: telegramEnabled, }); // Fetch Email filters const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'email-filters'], queryFn: adminBroadcastsApi.getEmailFilters, - enabled: channel === 'email', + enabled: emailEnabled, }); // Fetch buttons const { data: buttonsData } = useQuery({ queryKey: ['admin', 'broadcasts', 'buttons'], queryFn: adminBroadcastsApi.getButtons, - enabled: channel === 'telegram', + enabled: telegramEnabled, }); - // Preview mutations - const previewMutation = useMutation({ + // Preview mutations — separate for each channel + const telegramPreviewMutation = useMutation({ mutationFn: adminBroadcastsApi.preview, }); @@ -168,7 +173,7 @@ export default function AdminBroadcastCreate() { mutationFn: adminBroadcastsApi.previewEmail, }); - // Create mutation + // Create mutation (used for single-channel sends) const createMutation = useMutation({ mutationFn: adminBroadcastsApi.createCombined, onSuccess: (data) => { @@ -178,7 +183,7 @@ export default function AdminBroadcastCreate() { }); // Group Telegram filters - const groupedFilters = useMemo(() => { + const groupedTelegramFilters = useMemo(() => { if (!filtersData) return {}; const groups: Record = {}; @@ -215,48 +220,46 @@ export default function AdminBroadcastCreate() { return groups; }, [emailFiltersData]); - // Current filters based on channel - const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters; - const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading; + // Selected filter info for each channel + const selectedTelegramFilter = useMemo(() => { + if (!telegramTarget || !filtersData) return null; + const all = [ + ...filtersData.filters, + ...filtersData.tariff_filters, + ...filtersData.custom_filters, + ]; + return all.find((f) => f.key === telegramTarget) ?? null; + }, [telegramTarget, filtersData]); - // Selected filter info - const selectedFilter = useMemo(() => { - if (!target) return null; + const selectedEmailFilter = useMemo(() => { + if (!emailTarget || !emailFiltersData) return null; + return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null; + }, [emailTarget, emailFiltersData]); - if (channel === 'telegram' && filtersData) { - const all = [ - ...filtersData.filters, - ...filtersData.tariff_filters, - ...filtersData.custom_filters, - ]; - return all.find((f) => f.key === target); - } + // Handle toggling channels + const handleToggleTelegram = () => { + setTelegramEnabled((prev) => !prev); + setTelegramTarget(''); + telegramPreviewMutation.reset(); + }; - if (channel === 'email' && emailFiltersData) { - return emailFiltersData.filters.find((f) => f.key === target); - } - - return null; - }, [target, channel, filtersData, emailFiltersData]); - - // Handle channel change - const handleChannelChange = (newChannel: BroadcastChannel) => { - setChannel(newChannel); - setTarget(''); - previewMutation.reset(); + const handleToggleEmail = () => { + setEmailEnabled((prev) => !prev); + setEmailTarget(''); emailPreviewMutation.reset(); }; - // Handle filter selection - const handleFilterSelect = (filterKey: string) => { - setTarget(filterKey); - setShowFilters(false); + // Handle filter selection per channel + const handleTelegramFilterSelect = (filterKey: string) => { + setTelegramTarget(filterKey); + setShowTelegramFilters(false); + telegramPreviewMutation.mutate(filterKey); + }; - if (channel === 'telegram') { - previewMutation.mutate(filterKey); - } else { - emailPreviewMutation.mutate(filterKey); - } + const handleEmailFilterSelect = (filterKey: string) => { + setEmailTarget(filterKey); + setShowEmailFilters(false); + emailPreviewMutation.mutate(filterKey); }; // Handle file selection @@ -308,53 +311,163 @@ export default function AdminBroadcastCreate() { }; // Validate form + const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0; + const isEmailValid = + emailEnabled && emailTarget && emailSubject.trim().length > 0 && emailContent.trim().length > 0; + const isValid = useMemo(() => { - if (!target) return false; + if (!telegramEnabled && !emailEnabled) return false; + if (telegramEnabled && !isTelegramValid) return false; + if (emailEnabled && !isEmailValid) return false; + return true; + }, [telegramEnabled, emailEnabled, isTelegramValid, isEmailValid]); - if (channel === 'telegram') { - return messageText.trim().length > 0; - } - - if (channel === 'email') { - return emailSubject.trim().length > 0 && emailContent.trim().length > 0; - } - - return false; - }, [target, channel, messageText, emailSubject, emailContent]); + const bothChannels = telegramEnabled && emailEnabled; // Submit - const handleSubmit = () => { + const handleSubmit = async () => { if (!isValid) return; - const data: CombinedBroadcastCreateRequest = { - channel, - target, - }; - - if (channel === 'telegram') { - data.message_text = messageText; - data.selected_buttons = selectedButtons; - + // Single channel — use existing createMutation with navigation to detail + if (telegramEnabled && !emailEnabled) { + const data: CombinedBroadcastCreateRequest = { + channel: 'telegram', + target: telegramTarget, + message_text: messageText, + selected_buttons: selectedButtons, + }; if (uploadedFileId) { - data.media = { - type: mediaType, - file_id: uploadedFileId, - }; + data.media = { type: mediaType, file_id: uploadedFileId }; } + createMutation.mutate(data); + return; } - if (channel === 'email') { - data.email_subject = emailSubject; - data.email_html_content = emailContent; + if (emailEnabled && !telegramEnabled) { + const data: CombinedBroadcastCreateRequest = { + channel: 'email', + target: emailTarget, + email_subject: emailSubject, + email_html_content: emailContent, + }; + createMutation.mutate(data); + return; } - createMutation.mutate(data); + // Both channels — two sequential requests, navigate to list + setIsSubmitting(true); + try { + const telegramData: CombinedBroadcastCreateRequest = { + channel: 'telegram', + target: telegramTarget, + message_text: messageText, + selected_buttons: selectedButtons, + }; + if (uploadedFileId) { + telegramData.media = { type: mediaType, file_id: uploadedFileId }; + } + + const emailData: CombinedBroadcastCreateRequest = { + channel: 'email', + target: emailTarget, + email_subject: emailSubject, + email_html_content: emailContent, + }; + + await adminBroadcastsApi.createCombined(telegramData); + await adminBroadcastsApi.createCombined(emailData); + + queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); + navigate('/admin/broadcasts'); + } finally { + setIsSubmitting(false); + } }; - const recipientsCount = - channel === 'telegram' - ? (previewMutation.data?.count ?? selectedFilter?.count ?? null) - : (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null); + // Recipients counts per channel + const telegramRecipientsCount = telegramEnabled + ? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null) + : null; + + const emailRecipientsCount = emailEnabled + ? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null) + : null; + + const isPending = createMutation.isPending || isSubmitting; + + // Render filter dropdown + const renderFilterDropdown = ( + channelType: 'telegram' | 'email', + target: string, + selectedFilter: BroadcastFilter | TariffFilter | null, + recipientsCount: number | null, + showFilters: boolean, + setShowFilters: (v: boolean) => void, + handleFilterSelect: (key: string) => void, + groupedFilters: Record, + isLoading: boolean, + ) => ( +
+ +
+ + + {showFilters && ( +
+ {isLoading ? ( +
{t('common.loading')}
+ ) : ( + Object.entries(groupedFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group} +
+ {filters.map((filter) => ( + + ))} +
+ )) + )} +
+ )} +
+
+ ); return (
@@ -372,273 +485,250 @@ export default function AdminBroadcastCreate() {
- {/* Channel selection */} + {/* Channel toggles */}
+ {!telegramEnabled && !emailEnabled && ( +

{t('admin.broadcasts.atLeastOneChannel')}

+ )} + {bothChannels && ( +

{t('admin.broadcasts.sendingBoth')}

+ )}
- {/* Content */} -
- {/* Section title */} -

- {channel === 'telegram' - ? t('admin.broadcasts.telegramSection') - : t('admin.broadcasts.emailSection')} -

+ {/* Telegram section */} + {telegramEnabled && ( +
+

+ {t('admin.broadcasts.telegramSection')} +

- {/* Filter selection */} -
- -
-