mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
Merge PR #526: охват сегментов и прогресс доставки на странице отправки
This commit is contained in:
@@ -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<PromoOfferSegmentListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/segments');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get promo offer logs
|
||||
getLogs: async (params?: {
|
||||
limit?: number;
|
||||
|
||||
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
@@ -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<string, { bg: string; text: string; labelKey: string }> = {
|
||||
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 (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{isBroadcastInFlight(status) && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">{progressPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.total')}
|
||||
value={totalCount}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.sent')}
|
||||
value={sentCount}
|
||||
icon={<SendIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.blocked')}
|
||||
value={blockedCount}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.failed')}
|
||||
value={failedCount}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BroadcastDeliveryStats;
|
||||
@@ -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",
|
||||
|
||||
@@ -3305,7 +3305,9 @@
|
||||
"notificationsFailed": "(ناموفق: {{count}})",
|
||||
"sendError": "ارسال پیشنهاد ناموفق بود",
|
||||
"notificationsSent": "اعلانهای ارسال شده",
|
||||
"offersCreated": "پیشنهادهای ایجاد شده"
|
||||
"offersCreated": "پیشنهادهای ایجاد شده",
|
||||
"deliveryTitle": "تحویل در تلگرام",
|
||||
"openAsBroadcast": "نمایش بهعنوان ارسال گروهی"
|
||||
},
|
||||
"notFound": "قالب یافت نشد",
|
||||
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
||||
|
||||
@@ -4208,7 +4208,9 @@
|
||||
"sendError": "Не удалось отправить предложение",
|
||||
"notificationsSent": "Отправлено уведомлений",
|
||||
"offersCreated": "Создано офферов",
|
||||
"notificationsFailed": "(не отправлено: {{count}})"
|
||||
"notificationsFailed": "(не отправлено: {{count}})",
|
||||
"deliveryTitle": "Доставка в Telegram",
|
||||
"openAsBroadcast": "Открыть как рассылку"
|
||||
},
|
||||
"notFound": "Шаблон не найден",
|
||||
"noActiveTemplates": "Нет активных шаблонов",
|
||||
|
||||
@@ -3304,7 +3304,9 @@
|
||||
"notificationsFailed": "(失败: {{count}})",
|
||||
"sendError": "发送优惠失败",
|
||||
"notificationsSent": "已发送通知",
|
||||
"offersCreated": "已创建优惠"
|
||||
"offersCreated": "已创建优惠",
|
||||
"deliveryTitle": "Telegram 送达情况",
|
||||
"openAsBroadcast": "作为群发查看"
|
||||
},
|
||||
"notFound": "未找到模板",
|
||||
"noActiveTemplates": "没有活跃的模板",
|
||||
|
||||
@@ -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<string, { bg: string; text: string; labelKey: string }> = {
|
||||
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 (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{t('admin.broadcasts.detail')} #{broadcast.id}
|
||||
</h1>
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<BroadcastStatusBadge status={broadcast.status} />
|
||||
<ChannelBadge channel={broadcast.channel} />
|
||||
</div>
|
||||
<p className="text-sm text-dark-400">
|
||||
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{broadcast.progress_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
{/* Progress + stats */}
|
||||
<BroadcastDeliveryStats
|
||||
status={broadcast.status}
|
||||
progressPercent={broadcast.progress_percent}
|
||||
totalCount={broadcast.total_count}
|
||||
sentCount={broadcast.sent_count}
|
||||
blockedCount={broadcast.blocked_count}
|
||||
failedCount={broadcast.failed_count}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.total')}
|
||||
value={broadcast.total_count}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.sent')}
|
||||
value={broadcast.sent_count}
|
||||
icon={<SendIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.blocked')}
|
||||
value={broadcast.blocked_count}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.failed')}
|
||||
value={broadcast.failed_count}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
|
||||
@@ -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 (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<div className="mx-auto max-w-2xl py-12 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
|
||||
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
|
||||
<p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p>
|
||||
|
||||
{/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */}
|
||||
{delivery && (
|
||||
<div className="mb-6 space-y-4 text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-dark-300">
|
||||
{t('admin.promoOffers.result.deliveryTitle')}
|
||||
</span>
|
||||
<BroadcastStatusBadge status={delivery.status} />
|
||||
</div>
|
||||
<BroadcastDeliveryStats
|
||||
status={delivery.status}
|
||||
progressPercent={delivery.progress_percent}
|
||||
totalCount={delivery.total_count}
|
||||
sentCount={delivery.sent_count}
|
||||
blockedCount={delivery.blocked_count}
|
||||
failedCount={delivery.failed_count}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/broadcasts/${delivery.id}`)}
|
||||
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{t('admin.promoOffers.result.openAsBroadcast')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin/promo-offers')}
|
||||
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
|
||||
</div>
|
||||
|
||||
{sendMode === 'segment' ? (
|
||||
<>
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="input"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
|
||||
const count = segmentCounts.get(key);
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{t(labelKey)}
|
||||
{count === undefined
|
||||
? t(labelKey)
|
||||
: `${t(labelKey)} — ${count} ${t('admin.broadcasts.recipients')}`}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedSegmentCount !== undefined && (
|
||||
<div className="mt-2 text-sm text-dark-400">
|
||||
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||
<strong className="text-accent-400">{selectedSegmentCount}</strong>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div ref={searchRef} className="relative">
|
||||
{selectedUser ? (
|
||||
|
||||
27
src/utils/broadcastStatus.test.ts
Normal file
27
src/utils/broadcastStatus.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
13
src/utils/broadcastStatus.ts
Normal file
13
src/utils/broadcastStatus.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user