diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts index 533cfdd..0d9c338 100644 --- a/src/api/promoOffers.ts +++ b/src/api/promoOffers.ts @@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse { created_offers: number; user_ids: number[]; target: string | null; + /** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */ notifications_sent: number; notifications_failed: number; + /** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */ + broadcast_id: number | null; + telegram_recipients: number; +} + +export interface PromoOfferSegment { + key: string; + count: number; +} + +export interface PromoOfferSegmentListResponse { + segments: PromoOfferSegment[]; } export interface PromoOfferTemplate { @@ -206,6 +219,12 @@ export const promoOffersApi = { return response.data; }, + // Get recipient counts per target segment + getSegments: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/promo-offers/segments'); + return response.data; + }, + // Get promo offer logs getLogs: async (params?: { limit?: number; diff --git a/src/components/broadcasts/BroadcastDeliveryStats.tsx b/src/components/broadcasts/BroadcastDeliveryStats.tsx new file mode 100644 index 0000000..6bd7c29 --- /dev/null +++ b/src/components/broadcasts/BroadcastDeliveryStats.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from 'react-i18next'; +import { StatCard } from '@/components/stats'; +import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons'; +import { isBroadcastInFlight } from '../../utils/broadcastStatus'; + +const statusConfig: Record = { + queued: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.queued', + }, + in_progress: { + bg: 'bg-accent-500/20', + text: 'text-accent-400', + labelKey: 'admin.broadcasts.status.inProgress', + }, + completed: { + bg: 'bg-success-500/20', + text: 'text-success-400', + labelKey: 'admin.broadcasts.status.completed', + }, + partial: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.partial', + }, + failed: { + bg: 'bg-error-500/20', + text: 'text-error-400', + labelKey: 'admin.broadcasts.status.failed', + }, + cancelled: { + bg: 'bg-dark-500/20', + text: 'text-dark-400', + labelKey: 'admin.broadcasts.status.cancelled', + }, + cancelling: { + bg: 'bg-warning-500/20', + text: 'text-warning-400', + labelKey: 'admin.broadcasts.status.cancelling', + }, +}; + +export function BroadcastStatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); + const config = statusConfig[status] || statusConfig.queued; + return ( + + {t(config.labelKey)} + + ); +} + +export interface BroadcastDeliveryStatsProps { + status: string; + progressPercent: number; + totalCount: number; + sentCount: number; + blockedCount: number; + failedCount: number; +} + +/** + * Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько + * не доехало. Используется и на странице рассылки, и на отправке промопредложения. + */ +export function BroadcastDeliveryStats({ + status, + progressPercent, + totalCount, + sentCount, + blockedCount, + failedCount, +}: BroadcastDeliveryStatsProps) { + const { t } = useTranslation(); + + return ( +
+ {isBroadcastInFlight(status) && ( +
+
+ {t('admin.broadcasts.progress')} + {progressPercent.toFixed(1)}% +
+
+
+
+
+ )} + +
+ } + tone="neutral" + /> + } + tone="success" + /> + } + tone="warning" + /> + } + tone="error" + /> +
+
+ ); +} + +export default BroadcastDeliveryStats; diff --git a/src/locales/en.json b/src/locales/en.json index 6824c06..f0d830c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3807,7 +3807,9 @@ "sendError": "Failed to send offer", "notificationsSent": "Notifications sent", "offersCreated": "Offers created", - "notificationsFailed": "(failed: {{count}})" + "notificationsFailed": "(failed: {{count}})", + "deliveryTitle": "Telegram delivery", + "openAsBroadcast": "Open as broadcast" }, "notFound": "Template not found", "noActiveTemplates": "No active templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 224e3ec..4b48934 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3305,7 +3305,9 @@ "notificationsFailed": "(ناموفق: {{count}})", "sendError": "ارسال پیشنهاد ناموفق بود", "notificationsSent": "اعلان‌های ارسال شده", - "offersCreated": "پیشنهادهای ایجاد شده" + "offersCreated": "پیشنهادهای ایجاد شده", + "deliveryTitle": "تحویل در تلگرام", + "openAsBroadcast": "نمایش به‌عنوان ارسال گروهی" }, "notFound": "قالب یافت نشد", "noActiveTemplates": "قالب فعالی وجود ندارد", diff --git a/src/locales/ru.json b/src/locales/ru.json index 1cfef4f..0bcaacd 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4208,7 +4208,9 @@ "sendError": "Не удалось отправить предложение", "notificationsSent": "Отправлено уведомлений", "offersCreated": "Создано офферов", - "notificationsFailed": "(не отправлено: {{count}})" + "notificationsFailed": "(не отправлено: {{count}})", + "deliveryTitle": "Доставка в Telegram", + "openAsBroadcast": "Открыть как рассылку" }, "notFound": "Шаблон не найден", "noActiveTemplates": "Нет активных шаблонов", diff --git a/src/locales/zh.json b/src/locales/zh.json index 7ebb9af..3b1adc8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3304,7 +3304,9 @@ "notificationsFailed": "(失败: {{count}})", "sendError": "发送优惠失败", "notificationsSent": "已发送通知", - "offersCreated": "已创建优惠" + "offersCreated": "已创建优惠", + "deliveryTitle": "Telegram 送达情况", + "openAsBroadcast": "作为群发查看" }, "notFound": "未找到模板", "noActiveTemplates": "没有活跃的模板", diff --git a/src/pages/AdminBroadcastDetail.tsx b/src/pages/AdminBroadcastDetail.tsx index 1ee5c6c..1a378c0 100644 --- a/src/pages/AdminBroadcastDetail.tsx +++ b/src/pages/AdminBroadcastDetail.tsx @@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; -import { StatCard } from '@/components/stats'; import { - BanIcon, + BroadcastDeliveryStats, + BroadcastStatusBadge, +} from '../components/broadcasts/BroadcastDeliveryStats'; +import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus'; +import { DocumentIcon, EmailIcon, PhotoIcon, RefreshIcon, - SendIcon, StopIcon, TelegramIcon, - UsersIcon, VideoIcon, - XCircleIcon, } from '@/components/icons'; // Channel badge component @@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { ); } -// Status badge component -const statusConfig: Record = { - queued: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.queued', - }, - in_progress: { - bg: 'bg-accent-500/20', - text: 'text-accent-400', - labelKey: 'admin.broadcasts.status.inProgress', - }, - completed: { - bg: 'bg-success-500/20', - text: 'text-success-400', - labelKey: 'admin.broadcasts.status.completed', - }, - partial: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.partial', - }, - failed: { - bg: 'bg-error-500/20', - text: 'text-error-400', - labelKey: 'admin.broadcasts.status.failed', - }, - cancelled: { - bg: 'bg-dark-500/20', - text: 'text-dark-400', - labelKey: 'admin.broadcasts.status.cancelled', - }, - cancelling: { - bg: 'bg-warning-500/20', - text: 'text-warning-400', - labelKey: 'admin.broadcasts.status.cancelling', - }, -}; - -function StatusBadge({ status }: { status: string }) { - const { t } = useTranslation(); - const config = statusConfig[status] || statusConfig.queued; - return ( - - {t(config.labelKey)} - - ); -} - export default function AdminBroadcastDetail() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() { return adminBroadcastsApi.get(broadcastId); }, enabled: !!broadcastId && !isNaN(broadcastId), - refetchInterval: (query) => { - const data = query.state.data; - if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) { - return 3000; - } - return false; - }, + refetchInterval: (query) => broadcastPollInterval(query.state.data?.status), }); // Stop mutation @@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() { }, }); - const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status); + const isRunning = broadcast && isBroadcastInFlight(broadcast.status); if (!broadcastId || isNaN(broadcastId)) { navigate('/admin/broadcasts'); @@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {

{t('admin.broadcasts.detail')} #{broadcast.id}

- +

@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() { - {/* Progress */} - {isRunning && ( -

-
- {t('admin.broadcasts.progress')} - - {broadcast.progress_percent.toFixed(1)}% - -
-
-
-
-
- )} - - {/* Stats */} -
- } - tone="neutral" - /> - } - tone="success" - /> - } - tone="warning" - /> - } - tone="error" - /> -
+ {/* Progress + stats */} + {/* Target */}
diff --git a/src/pages/AdminPromoOfferSend.tsx b/src/pages/AdminPromoOfferSend.tsx index e8e26a6..cd2aec4 100644 --- a/src/pages/AdminPromoOfferSend.tsx +++ b/src/pages/AdminPromoOfferSend.tsx @@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { promoOffersApi, - PromoOfferBroadcastRequest, + type PromoOfferBroadcastRequest, TARGET_SEGMENTS, - TargetSegment, + type TargetSegment, OFFER_TYPE_CONFIG, - OfferType, + type OfferType, } from '../api/promoOffers'; -import { adminUsersApi, UserListItem } from '../api/adminUsers'; +import { adminBroadcastsApi } from '../api/adminBroadcasts'; +import { adminUsersApi, type UserListItem } from '../api/adminUsers'; import { AdminBackButton } from '../components/admin'; +import { + BroadcastDeliveryStats, + BroadcastStatusBadge, +} from '../components/broadcasts/BroadcastDeliveryStats'; +import { broadcastPollInterval } from '../utils/broadcastStatus'; import { SendIcon, CheckIcon, @@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() { title: string; message: string; isSuccess: boolean; + broadcastId?: number | null; } | null>(null); // Query templates @@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() { queryFn: promoOffersApi.getTemplates, }); + // Recipient counts per segment — админ видит охват до отправки + const { data: segmentsData } = useQuery({ + queryKey: ['admin-promo-segments'], + queryFn: promoOffersApi.getSegments, + staleTime: 60000, + }); + + const segmentCounts = new Map( + (segmentsData?.segments || []).map((segment) => [segment.key, segment.count]), + ); + const selectedSegmentCount = segmentCounts.get(selectedTarget); + + // Delivery progress of the offer we have just sent + const broadcastId = result?.broadcastId ?? null; + const { data: delivery } = useQuery({ + queryKey: ['admin', 'broadcasts', 'detail', broadcastId], + queryFn: async () => adminBroadcastsApi.get(broadcastId as number), + enabled: broadcastId !== null, + refetchInterval: (query) => broadcastPollInterval(query.state.data?.status), + }); + const templates = templatesData?.items || []; const activeTemplates = templates.filter((t) => t.is_active); const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); @@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() { mutationFn: promoOffersApi.broadcastOffer, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers }); if (data.notifications_sent > 0 || data.notifications_failed > 0) { @@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() { title: t('admin.promoOffers.result.sentTitle'), message, isSuccess: true, + broadcastId: data.broadcast_id, }); }, onError: (error: unknown) => { @@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() { if (result) { return (
-
+

{result.title}

{result.message}

+ + {/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */} + {delivery && ( +
+
+ + {t('admin.promoOffers.result.deliveryTitle')} + + +
+ + +
+ )} +
{sendMode === 'segment' ? ( - + <> + + {selectedSegmentCount !== undefined && ( +
+ {t('admin.broadcasts.willBeSent')}:{' '} + {selectedSegmentCount} +
+ )} + ) : (
{selectedUser ? ( diff --git a/src/utils/broadcastStatus.test.ts b/src/utils/broadcastStatus.test.ts new file mode 100644 index 0000000..52e872d --- /dev/null +++ b/src/utils/broadcastStatus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus'; + +describe('broadcastStatus', () => { + it('treats queued, in-progress and cancelling deliveries as running', () => { + expect(isBroadcastInFlight('queued')).toBe(true); + expect(isBroadcastInFlight('in_progress')).toBe(true); + expect(isBroadcastInFlight('cancelling')).toBe(true); + }); + + it('treats finished deliveries as done', () => { + expect(isBroadcastInFlight('completed')).toBe(false); + expect(isBroadcastInFlight('partial')).toBe(false); + expect(isBroadcastInFlight('failed')).toBe(false); + expect(isBroadcastInFlight('cancelled')).toBe(false); + }); + + it('handles a missing status without polling', () => { + expect(isBroadcastInFlight(undefined)).toBe(false); + expect(broadcastPollInterval(undefined)).toBe(false); + }); + + it('polls only while the delivery is running', () => { + expect(broadcastPollInterval('in_progress')).toBe(3000); + expect(broadcastPollInterval('completed')).toBe(false); + }); +}); diff --git a/src/utils/broadcastStatus.ts b/src/utils/broadcastStatus.ts new file mode 100644 index 0000000..52fb06c --- /dev/null +++ b/src/utils/broadcastStatus.ts @@ -0,0 +1,13 @@ +/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */ +export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const; + +/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */ +export function isBroadcastInFlight(status: string | undefined): boolean { + if (!status) return false; + return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status); +} + +/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */ +export function broadcastPollInterval(status: string | undefined): number | false { + return isBroadcastInFlight(status) ? 3000 : false; +}