diff --git a/frontend/src/classes/Campaign.ts b/frontend/src/classes/Campaign.ts index 080309c11..8ae0e1adf 100644 --- a/frontend/src/classes/Campaign.ts +++ b/frontend/src/classes/Campaign.ts @@ -23,11 +23,11 @@ export class Campaign { id: number name: string type: ChannelType - createdAt: Date - sentAt: Date + createdAt: string + sentAt?: string status: Status isCsvProcessing: boolean - statusUpdatedAt: Date + statusUpdatedAt?: string protect: boolean redacted: boolean demoMessageLimit: number | null @@ -71,8 +71,8 @@ export class CampaignStats { sent: number invalid: number status: Status - statusUpdatedAt: Date // Timestamp when job's status was changed to this status - updatedAt: Date // Timestamp when statistic was updated + statusUpdatedAt: string // Timestamp when job's status was changed to this status + updatedAt: string // Timestamp when statistic was updated halted?: boolean waitTime?: number redacted?: boolean diff --git a/frontend/src/components/common/export-recipients/ExportRecipients.tsx b/frontend/src/components/common/export-recipients/ExportRecipients.tsx index afb096854..936b8d8f3 100644 --- a/frontend/src/components/common/export-recipients/ExportRecipients.tsx +++ b/frontend/src/components/common/export-recipients/ExportRecipients.tsx @@ -33,9 +33,9 @@ const ExportRecipients = ({ campaignId: number campaignName: string campaignType: ChannelType - sentAt: Date + sentAt?: string status: Status - statusUpdatedAt: Date + statusUpdatedAt?: string iconPosition: 'left' | 'right' isButton?: boolean }) => { @@ -48,7 +48,7 @@ const ExportRecipients = ({ let timeoutId: NodeJS.Timeout function getExportStatus(): void { - if (status === Status.Sending) { + if (status === Status.Sending || !statusUpdatedAt) { return setExportStatus(CampaignExportStatus.Unavailable) } @@ -104,7 +104,16 @@ const ExportRecipients = ({ content = content.concat(emptyExplanation) } - const sentAtTime = new Date(sentAt) + let sentAtTime + if (!sentAt) { + console.error( + 'sentAt is undefined. Using current Date() as a fallback.' + ) + sentAtTime = new Date() + } else { + sentAtTime = new Date(sentAt) + } + download( new Blob(content), `${campaignName}_${sentAtTime.toLocaleDateString()}_${sentAtTime.toLocaleTimeString()}.csv`, diff --git a/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts b/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts new file mode 100644 index 000000000..1cedb2f5f --- /dev/null +++ b/frontend/src/components/custom-hooks/use-poll-campaign-stats.ts @@ -0,0 +1,40 @@ +import { CampaignStats, Status } from 'classes' +import { useState, useEffect, useCallback, useContext } from 'react' +import { getCampaignStats, getCampaignDetails } from 'services/campaign.service' +import { CampaignContext } from 'contexts/campaign.context' + +function usePollCampaignStats() { + const { campaign, setCampaign } = useContext(CampaignContext) + const { id } = campaign + const [stats, setStats] = useState(new CampaignStats({})) + + const refreshCampaignStats = useCallback( + async (forceRefresh = false) => { + const updatedStats = await getCampaignStats(id, forceRefresh) + setStats(updatedStats) + return updatedStats + }, + [id] + ) + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + async function poll() { + const { status } = await refreshCampaignStats() + if (status !== Status.Sent) { + timeoutId = setTimeout(poll, 2000) + } else { + const updatedCampaign = await getCampaignDetails(id) + setCampaign(updatedCampaign) + } + } + poll() + + return () => clearTimeout(timeoutId) + }, [stats.status, refreshCampaignStats, id, setCampaign]) + + return { stats, refreshCampaignStats } +} + +export default usePollCampaignStats diff --git a/frontend/src/components/dashboard/create/email/EmailDetail.tsx b/frontend/src/components/dashboard/create/email/EmailDetail.tsx index 7c0a783a3..1594c6fef 100644 --- a/frontend/src/components/dashboard/create/email/EmailDetail.tsx +++ b/frontend/src/components/dashboard/create/email/EmailDetail.tsx @@ -1,29 +1,20 @@ -import React, { useState, useEffect, useContext } from 'react' +import React, { useContext } from 'react' import { CampaignContext } from 'contexts/campaign.context' -import { Status, CampaignStats, ChannelType } from 'classes/Campaign' -import { - getCampaignStats, - stopCampaign, - retryCampaign, -} from 'services/campaign.service' +import { Status, ChannelType } from 'classes/Campaign' +import { stopCampaign, retryCampaign } from 'services/campaign.service' import { StepHeader, ProgressDetails } from 'components/common' import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' +import usePollCampaignStats from 'components/custom-hooks/use-poll-campaign-stats' const EmailDetail = () => { const { campaign } = useContext(CampaignContext) const { id } = campaign - const [stats, setStats] = useState(new CampaignStats({})) - - async function refreshCampaignStats(id: number, forceRefresh = false) { - const campaignStats = await getCampaignStats(id, forceRefresh) - setStats(campaignStats) - return campaignStats - } + const { stats, refreshCampaignStats } = usePollCampaignStats() async function handleRefreshStats() { try { - await refreshCampaignStats(id, true) + await refreshCampaignStats(true) } catch (err) { console.error(err) } @@ -33,7 +24,7 @@ const EmailDetail = () => { try { sendUserEvent(GA_USER_EVENTS.PAUSE_SENDING, ChannelType.Email) await stopCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } @@ -43,29 +34,12 @@ const EmailDetail = () => { try { sendUserEvent(GA_USER_EVENTS.RETRY_RESUME_SENDING, ChannelType.Email) await retryCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } } - useEffect(() => { - let timeoutId: NodeJS.Timeout - - async function poll() { - const { status } = await refreshCampaignStats(id) - - if (status !== Status.Sent) { - timeoutId = setTimeout(poll, 2000) - } - } - - poll() - return () => { - timeoutId && clearTimeout(timeoutId) - } - }, [id, stats.status]) - function renderProgressHeader() { if (stats.waitTime && stats.waitTime > 0) { const waitMin = Math.ceil(stats.waitTime / 60) diff --git a/frontend/src/components/dashboard/create/email/EmailSend.tsx b/frontend/src/components/dashboard/create/email/EmailSend.tsx index 7de69791c..c76a0efdb 100644 --- a/frontend/src/components/dashboard/create/email/EmailSend.tsx +++ b/frontend/src/components/dashboard/create/email/EmailSend.tsx @@ -8,7 +8,7 @@ import React, { import { useParams } from 'react-router-dom' import { CampaignContext } from 'contexts/campaign.context' -import { EmailProgress, Status } from 'classes' +import { ChannelType, EmailProgress } from 'classes' import { ModalContext } from 'contexts/modal.context' import { EmailPreviewBlock, @@ -20,8 +20,7 @@ import { StepSection, } from 'components/common' import { getPreviewMessage } from 'services/email.service' -import { sendCampaign } from 'services/campaign.service' - +import { confirmSendCampaign } from '../util' import styles from '../Create.module.scss' const EmailSend = ({ @@ -61,9 +60,13 @@ const EmailSend = ({ loadPreview(campaignId) }, [campaignId]) - const onModalConfirm = async () => { - await sendCampaign(+campaignId, 0) - updateCampaign({ status: Status.Sending }) + const onModalConfirm = () => { + confirmSendCampaign({ + campaignId: +campaignId, + sendRate: 0, + channelType: ChannelType.Email, + updateCampaign, + }) } const openModal = () => { diff --git a/frontend/src/components/dashboard/create/sms/SMSDetail.tsx b/frontend/src/components/dashboard/create/sms/SMSDetail.tsx index 3bc848f94..9783117de 100644 --- a/frontend/src/components/dashboard/create/sms/SMSDetail.tsx +++ b/frontend/src/components/dashboard/create/sms/SMSDetail.tsx @@ -1,33 +1,24 @@ -import React, { useState, useEffect, useContext } from 'react' +import React, { useEffect, useContext } from 'react' import { CampaignContext } from 'contexts/campaign.context' -import { Status, CampaignStats, ChannelType } from 'classes/Campaign' -import { - getCampaignStats, - stopCampaign, - retryCampaign, -} from 'services/campaign.service' +import { Status, ChannelType } from 'classes/Campaign' +import { stopCampaign, retryCampaign } from 'services/campaign.service' import { StepHeader, ProgressDetails } from 'components/common' import { ModalContext } from 'contexts/modal.context' import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import CompletedDemoModal from 'components/dashboard/demo/completed-demo-modal' +import usePollCampaignStats from 'components/custom-hooks/use-poll-campaign-stats' const SMSDetail = () => { const { setModalContent } = useContext(ModalContext) // Destructured to avoid the addition of modalContext to useEffect's dependencies const { campaign } = useContext(CampaignContext) const { id, demoMessageLimit } = campaign const isDemo = !!demoMessageLimit - const [stats, setStats] = useState(new CampaignStats({})) - - async function refreshCampaignStats(id: number, forceRefresh = false) { - const campaignStats = await getCampaignStats(id, forceRefresh) - setStats(campaignStats) - return campaignStats - } + const { stats, refreshCampaignStats } = usePollCampaignStats() async function handleRefreshStats() { try { - await refreshCampaignStats(id, true) + await refreshCampaignStats(true) } catch (err) { console.error(err) } @@ -37,7 +28,7 @@ const SMSDetail = () => { try { sendUserEvent(GA_USER_EVENTS.PAUSE_SENDING, ChannelType.SMS) await stopCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } @@ -47,29 +38,12 @@ const SMSDetail = () => { try { sendUserEvent(GA_USER_EVENTS.RETRY_RESUME_SENDING, ChannelType.SMS) await retryCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } } - useEffect(() => { - let timeoutId: NodeJS.Timeout - - async function poll() { - const { status } = await refreshCampaignStats(id) - - if (status !== Status.Sent) { - timeoutId = setTimeout(poll, 2000) - } - } - - poll() - return () => { - timeoutId && clearTimeout(timeoutId) - } - }, [id, stats.status]) - useEffect(() => { function renderCompletedDemoModal() { setModalContent( diff --git a/frontend/src/components/dashboard/create/sms/SMSSend.tsx b/frontend/src/components/dashboard/create/sms/SMSSend.tsx index cb5364b1d..496c3404b 100644 --- a/frontend/src/components/dashboard/create/sms/SMSSend.tsx +++ b/frontend/src/components/dashboard/create/sms/SMSSend.tsx @@ -2,7 +2,7 @@ import React, { useContext, useState, useEffect } from 'react' import { useParams } from 'react-router-dom' import { CampaignContext } from 'contexts/campaign.context' -import { Status, ChannelType } from 'classes' +import { ChannelType } from 'classes' import { ModalContext } from 'contexts/modal.context' import { PreviewBlock, @@ -15,12 +15,10 @@ import { StepSection, } from 'components/common' import { getPreviewMessage } from 'services/sms.service' -import { sendCampaign } from 'services/campaign.service' -import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import type { SMSProgress } from 'classes' import type { Dispatch, SetStateAction } from 'react' - +import { confirmSendCampaign } from '../util' import styles from '../Create.module.scss' const SMSSend = ({ @@ -54,12 +52,13 @@ const SMSSend = ({ loadPreview(campaignId) }, [campaignId]) - const onModalConfirm = async () => { - await sendCampaign(+campaignId, +sendRate) - if (sendRate) { - sendUserEvent(GA_USER_EVENTS.USE_SEND_RATE, ChannelType.SMS) - } - updateCampaign({ status: Status.Sending }) + const onModalConfirm = () => { + confirmSendCampaign({ + campaignId: +campaignId, + sendRate: +sendRate, + channelType: ChannelType.SMS, + updateCampaign, + }) } const openModal = () => { diff --git a/frontend/src/components/dashboard/create/telegram/TelegramDetail.tsx b/frontend/src/components/dashboard/create/telegram/TelegramDetail.tsx index 428741184..9158cb66a 100644 --- a/frontend/src/components/dashboard/create/telegram/TelegramDetail.tsx +++ b/frontend/src/components/dashboard/create/telegram/TelegramDetail.tsx @@ -1,33 +1,24 @@ -import React, { useState, useEffect, useContext } from 'react' +import React, { useEffect, useContext } from 'react' import { CampaignContext } from 'contexts/campaign.context' -import { Status, CampaignStats, ChannelType } from 'classes/Campaign' -import { - getCampaignStats, - stopCampaign, - retryCampaign, -} from 'services/campaign.service' +import { Status, ChannelType } from 'classes/Campaign' +import { stopCampaign, retryCampaign } from 'services/campaign.service' import { StepHeader, ProgressDetails } from 'components/common' import { ModalContext } from 'contexts/modal.context' import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import CompletedDemoModal from 'components/dashboard/demo/completed-demo-modal' +import usePollCampaignStats from 'components/custom-hooks/use-poll-campaign-stats' const TelegramDetail = () => { const { setModalContent } = useContext(ModalContext) // Destructured to avoid the addition of modalContext to useEffect's dependencies const { campaign } = useContext(CampaignContext) const { id, demoMessageLimit } = campaign const isDemo = !!demoMessageLimit - const [stats, setStats] = useState(new CampaignStats({})) - - async function refreshCampaignStats(id: number, forceRefresh = false) { - const campaignStats = await getCampaignStats(id, forceRefresh) - setStats(campaignStats) - return campaignStats - } + const { stats, refreshCampaignStats } = usePollCampaignStats() async function handleRefreshStats() { try { - await refreshCampaignStats(id, true) + await refreshCampaignStats(true) } catch (err) { console.error(err) } @@ -37,7 +28,7 @@ const TelegramDetail = () => { try { sendUserEvent(GA_USER_EVENTS.PAUSE_SENDING, ChannelType.Telegram) await stopCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } @@ -47,29 +38,12 @@ const TelegramDetail = () => { try { sendUserEvent(GA_USER_EVENTS.RETRY_RESUME_SENDING, ChannelType.Telegram) await retryCampaign(id) - await refreshCampaignStats(id) + await refreshCampaignStats() } catch (err) { console.error(err) } } - useEffect(() => { - let timeoutId: NodeJS.Timeout - - async function poll() { - const { status } = await refreshCampaignStats(id) - - if (status !== Status.Sent) { - timeoutId = setTimeout(poll, 2000) - } - } - - poll() - return () => { - timeoutId && clearTimeout(timeoutId) - } - }, [id, stats.status]) - useEffect(() => { function renderCompletedDemoModal() { setModalContent( diff --git a/frontend/src/components/dashboard/create/telegram/TelegramSend.tsx b/frontend/src/components/dashboard/create/telegram/TelegramSend.tsx index 7192edd44..626cb1776 100644 --- a/frontend/src/components/dashboard/create/telegram/TelegramSend.tsx +++ b/frontend/src/components/dashboard/create/telegram/TelegramSend.tsx @@ -2,7 +2,7 @@ import React, { useContext, useState, useEffect } from 'react' import { useParams } from 'react-router-dom' import { CampaignContext } from 'contexts/campaign.context' -import { Status, ChannelType } from 'classes' +import { ChannelType } from 'classes' import { ModalContext } from 'contexts/modal.context' import { PreviewBlock, @@ -15,12 +15,10 @@ import { StepSection, } from 'components/common' import { getPreviewMessage } from 'services/telegram.service' -import { sendCampaign } from 'services/campaign.service' -import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' import type { TelegramProgress } from 'classes' import type { Dispatch, SetStateAction } from 'react' - +import { confirmSendCampaign } from '../util' import styles from '../Create.module.scss' const TelegramSend = ({ @@ -54,12 +52,13 @@ const TelegramSend = ({ loadPreview(campaignId) }, [campaignId]) - const onModalConfirm = async () => { - await sendCampaign(+campaignId, +sendRate) - if (sendRate) { - sendUserEvent(GA_USER_EVENTS.USE_SEND_RATE, ChannelType.Telegram) - } - updateCampaign({ status: Status.Sending }) + const onModalConfirm = () => { + confirmSendCampaign({ + campaignId: +campaignId, + sendRate: +sendRate, + channelType: ChannelType.Telegram, + updateCampaign, + }) } const openModal = () => { diff --git a/frontend/src/components/dashboard/create/util.ts b/frontend/src/components/dashboard/create/util.ts new file mode 100644 index 000000000..fbaed9f7b --- /dev/null +++ b/frontend/src/components/dashboard/create/util.ts @@ -0,0 +1,21 @@ +import { sendCampaign } from 'services/campaign.service' +import { Campaign, ChannelType, Status } from 'classes' +import { GA_USER_EVENTS, sendUserEvent } from 'services/ga.service' + +export const confirmSendCampaign = async ({ + campaignId, + sendRate, + channelType, + updateCampaign, +}: { + campaignId: number + sendRate: number + channelType: ChannelType + updateCampaign: (campaign: Partial) => void +}) => { + await sendCampaign(campaignId, sendRate) + if (sendRate) { + sendUserEvent(GA_USER_EVENTS.USE_SEND_RATE, channelType) + } + updateCampaign({ status: Status.Sending }) +} diff --git a/frontend/src/services/campaign.service.ts b/frontend/src/services/campaign.service.ts index d80df6279..008640622 100644 --- a/frontend/src/services/campaign.service.ts +++ b/frontend/src/services/campaign.service.ts @@ -15,8 +15,8 @@ import { import type { CampaignRecipient } from 'classes' function getJobTimestamps( - jobs: Array<{ sent_at: Date; status_updated_at: Date }> -): { sentAt: Date; statusUpdatedAt: Date } { + jobs: Array<{ sent_at: string; status_updated_at: string }> +): { sentAt: string; statusUpdatedAt: string } { const jobsSentAt = jobs.map((x) => x.sent_at).sort() const jobsUpdatedAt = jobs.map((x) => x.status_updated_at).sort() // returns job with the earliest sentAt time