From 045e4b31fc34843d873d839bdbd1174fc320e9b5 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 15:49:31 +0700 Subject: [PATCH 1/2] feat(config): add campaign cost rate settings --- src/config.js | 12 ++++++++++++ src/global.d.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/config.js b/src/config.js index c676c8cdb..87281af08 100644 --- a/src/config.js +++ b/src/config.js @@ -476,6 +476,12 @@ const validators = { default: 99999, isClient: true }), + MMS_MESSAGE_COST: num({ + desc: + "Estimated USD cost for each outbound MMS message, shown in campaign stats.", + default: 0.0325, + isClient: true + }), METRICS_ENABLED: bool({ desc: "Whether to expose the /metrics endpoint for Prometheus scraping.", default: true @@ -613,6 +619,12 @@ const validators = { default: 100, isClient: true }), + SMS_SEGMENT_COST: num({ + desc: + "Estimated USD cost for each outbound SMS segment, shown in campaign stats.", + default: 0.013, + isClient: true + }), SESSION_SECRET: str({ desc: "Unique key used to encrypt sessions." }), diff --git a/src/global.d.ts b/src/global.d.ts index e0f50154d..abe874c72 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -7,6 +7,7 @@ interface Window { GRAPHQL_URL: string; SPOKE_VERSION: string; MAX_MESSAGE_LENGTH: number; + MMS_MESSAGE_COST: number; PASSPORT_STRATEGY: string; TZ: string; RENDERED_CLASS_NAMES: string[]; @@ -15,6 +16,7 @@ interface Window { BASE_URL: string; ENABLE_TROLLBOT: boolean; ENABLE_AUTOSENDING: boolean; + SMS_SEGMENT_COST: number; CHATWOOT_WEBSITE_TOKEN?: string; CHATWOOT_BASE_URL?: string; From 044e12c06de19b5f9b7f44ce0e026401e7f52bae Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 31 Aug 2026 15:49:50 +0700 Subject: [PATCH 2/2] feat(campaign-stats): show estimated campaign cost --- .../components/CampaignCostStat.tsx | 118 ++++++++++++++++++ .../components/TopLineStats.jsx | 17 +-- 2 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 src/containers/AdminCampaignStats/components/CampaignCostStat.tsx diff --git a/src/containers/AdminCampaignStats/components/CampaignCostStat.tsx b/src/containers/AdminCampaignStats/components/CampaignCostStat.tsx new file mode 100644 index 000000000..cd9596f47 --- /dev/null +++ b/src/containers/AdminCampaignStats/components/CampaignCostStat.tsx @@ -0,0 +1,118 @@ +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import React, { useState } from "react"; + +import CampaignStat from "./CampaignStat"; + +const useStyles = makeStyles((theme) => ({ + clickable: { + cursor: "pointer", + height: "100%" + }, + breakdown: { + display: "grid", + gridTemplateColumns: "1fr auto auto", + gap: theme.spacing(1, 3), + marginBottom: theme.spacing(2) + }, + total: { + fontWeight: 700 + } +})); + +const formatUsd = (amount: number, maximumFractionDigits = 2) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits + }).format(amount); + +interface CampaignCostStatProps { + loading?: boolean; + error?: string; + smsSegments?: number; + mmsMessages?: number; +} + +export const CampaignCostStat: React.FC = ({ + loading, + error, + smsSegments, + mmsMessages +}) => { + const classes = useStyles(); + const [open, setOpen] = useState(false); + const smsCost = (smsSegments ?? 0) * window.SMS_SEGMENT_COST; + const mmsCost = (mmsMessages ?? 0) * window.MMS_MESSAGE_COST; + const estimatedCost = smsCost + mmsCost; + const hasStats = smsSegments !== undefined && mmsMessages !== undefined; + const openDialog = () => setOpen(true); + + return ( + <> +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDialog(); + } + } + : undefined + } + role={hasStats ? "button" : undefined} + tabIndex={hasStats ? 0 : undefined} + > + +
+ setOpen(false)}> + Estimated Campaign Cost + +
+ SMS segments + + {smsSegments} × {formatUsd(window.SMS_SEGMENT_COST, 4)} + + {formatUsd(smsCost)} + MMS messages + + {mmsMessages} × {formatUsd(window.MMS_MESSAGE_COST, 4)} + + {formatUsd(mmsCost)} + Estimated total + + + {formatUsd(estimatedCost)} + +
+ + This estimate is based on the campaign's current SMS and MMS usage. + Final billing costs may vary. + +
+ + + +
+ + ); +}; + +export default CampaignCostStat; diff --git a/src/containers/AdminCampaignStats/components/TopLineStats.jsx b/src/containers/AdminCampaignStats/components/TopLineStats.jsx index 6fe8a53bf..c3787bdfc 100644 --- a/src/containers/AdminCampaignStats/components/TopLineStats.jsx +++ b/src/containers/AdminCampaignStats/components/TopLineStats.jsx @@ -5,6 +5,7 @@ import React from "react"; import { Link } from "react-router-dom"; import { withQueries } from "../../hoc/with-operations"; +import CampaignCostStat from "./CampaignCostStat"; import CampaignStat from "./CampaignStat"; export const TopLineStats = (props) => { @@ -97,23 +98,15 @@ export const TopLineStats = (props) => { } /> - - + - - -