@aglyn/shared-ui-email-campaigns 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +73 -0
- package/package.json +39 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/campaign-picker.component.d.ts +54 -0
- package/src/lib/components/campaign-picker.component.js +121 -0
- package/src/lib/components/campaign-picker.component.js.map +1 -0
- package/src/lib/components/report-figures.d.ts +51 -0
- package/src/lib/components/report-figures.js +120 -0
- package/src/lib/components/report-figures.js.map +1 -0
- package/src/lib/model/campaign-container.d.ts +359 -0
- package/src/lib/model/campaign-container.js +355 -0
- package/src/lib/model/campaign-container.js.map +1 -0
- package/src/lib/model/campaign-conversions.d.ts +286 -0
- package/src/lib/model/campaign-conversions.js +249 -0
- package/src/lib/model/campaign-conversions.js.map +1 -0
- package/src/lib/model/campaign-report.d.ts +304 -0
- package/src/lib/model/campaign-report.js +326 -0
- package/src/lib/model/campaign-report.js.map +1 -0
- package/src/lib/model/campaign-revenue.d.ts +327 -0
- package/src/lib/model/campaign-revenue.js +332 -0
- package/src/lib/model/campaign-revenue.js.map +1 -0
- package/src/lib/model/campaign-send-time.d.ts +74 -0
- package/src/lib/model/campaign-send-time.js +120 -0
- package/src/lib/model/campaign-send-time.js.map +1 -0
- package/src/lib/model/email-record.d.ts +175 -0
- package/src/lib/model/email-record.js +198 -0
- package/src/lib/model/email-record.js.map +1 -0
- package/src/lib/model/index.d.ts +53 -0
- package/src/lib/model/index.js +48 -0
- package/src/lib/model/index.js.map +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /*
|
|
18
|
+
* The four figure primitives now live in `@aglyn/shared-ui-jsx` so the forms
|
|
19
|
+
* report and the email reports share one implementation of "a rate names its
|
|
20
|
+
* denominator". Re-exported here because the email cards import them from
|
|
21
|
+
* this path, and a rate that renders differently on two surfaces is the thing
|
|
22
|
+
* the shared copy exists to prevent.
|
|
23
|
+
*/ export { Figure, percent, RateRow, Section } from "@aglyn/shared-ui-jsx/components/measured-figures.component";
|
|
24
|
+
import { Stack, Typography } from "@mui/material";
|
|
25
|
+
/*
|
|
26
|
+
* Money stays here. It is not a figure primitive: it carries a currency, and
|
|
27
|
+
* this surface deliberately refuses to sum two of them into one number.
|
|
28
|
+
*/ export function money(cents, currency) {
|
|
29
|
+
const amount = cents / 100;
|
|
30
|
+
try {
|
|
31
|
+
return new Intl.NumberFormat(undefined, {
|
|
32
|
+
style: 'currency',
|
|
33
|
+
currency: currency.toUpperCase()
|
|
34
|
+
}).format(amount);
|
|
35
|
+
} catch (unused) {
|
|
36
|
+
return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One money figure, with the population it is averaged over named.
|
|
41
|
+
*
|
|
42
|
+
* The same rule {@link RateRow} enforces for a percentage, applied to an
|
|
43
|
+
* average — they go wrong the same way. "$0.42 per recipient" is meaningless
|
|
44
|
+
* without the recipient count, and worse than meaningless when the reader
|
|
45
|
+
* assumes a different one from the writer.
|
|
46
|
+
*/ export function MoneyPerMessageRow(props) {
|
|
47
|
+
const { label, figure } = props;
|
|
48
|
+
return /*#__PURE__*/ _jsxs(Stack, {
|
|
49
|
+
direction: "row",
|
|
50
|
+
spacing: 2,
|
|
51
|
+
sx: {
|
|
52
|
+
justifyContent: 'space-between',
|
|
53
|
+
alignItems: 'baseline'
|
|
54
|
+
},
|
|
55
|
+
children: [
|
|
56
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
57
|
+
variant: "body2",
|
|
58
|
+
children: label
|
|
59
|
+
}),
|
|
60
|
+
figure ? /*#__PURE__*/ _jsxs(Stack, {
|
|
61
|
+
direction: "row",
|
|
62
|
+
spacing: 1,
|
|
63
|
+
sx: {
|
|
64
|
+
alignItems: 'baseline'
|
|
65
|
+
},
|
|
66
|
+
children: [
|
|
67
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
68
|
+
variant: "body2",
|
|
69
|
+
sx: {
|
|
70
|
+
fontWeight: 'bold'
|
|
71
|
+
},
|
|
72
|
+
children: money(figure.cents, figure.currency)
|
|
73
|
+
}),
|
|
74
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
75
|
+
variant: "caption",
|
|
76
|
+
color: "text.secondary",
|
|
77
|
+
children: `${money(figure.numeratorCents, figure.currency)} over ${figure.denominator.toLocaleString()} ${figure.denominatorLabel}`
|
|
78
|
+
})
|
|
79
|
+
]
|
|
80
|
+
}) : /*#__PURE__*/ _jsx(Typography, {
|
|
81
|
+
variant: "caption",
|
|
82
|
+
color: "text.secondary",
|
|
83
|
+
children: '— not enough recorded to compute'
|
|
84
|
+
})
|
|
85
|
+
]
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
MoneyPerMessageRow.displayName = 'MoneyPerMessageRow';
|
|
89
|
+
/**
|
|
90
|
+
* One money total, with what it counts named underneath.
|
|
91
|
+
*
|
|
92
|
+
* {@link Figure}'s contract in currency: `null` draws a dash and never a
|
|
93
|
+
* zero, because "no attribution has ever been recorded" and "this campaign
|
|
94
|
+
* earned nothing" lead a merchant to opposite conclusions about whether to
|
|
95
|
+
* send another one.
|
|
96
|
+
*/ export function MoneyFigure(props) {
|
|
97
|
+
return /*#__PURE__*/ _jsxs(Stack, {
|
|
98
|
+
sx: {
|
|
99
|
+
minWidth: 140
|
|
100
|
+
},
|
|
101
|
+
children: [
|
|
102
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
103
|
+
variant: "h6",
|
|
104
|
+
children: props.cents === null ? '—' : money(props.cents, props.currency)
|
|
105
|
+
}),
|
|
106
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
107
|
+
variant: "body2",
|
|
108
|
+
children: props.label
|
|
109
|
+
}),
|
|
110
|
+
/*#__PURE__*/ _jsx(Typography, {
|
|
111
|
+
variant: "caption",
|
|
112
|
+
color: "text.secondary",
|
|
113
|
+
children: props.cents === null ? 'not recorded' : props.note
|
|
114
|
+
})
|
|
115
|
+
]
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
MoneyFigure.displayName = 'MoneyFigure';
|
|
119
|
+
|
|
120
|
+
//# sourceMappingURL=report-figures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/components/report-figures.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The four figure primitives now live in `@aglyn/shared-ui-jsx` so the forms\n * report and the email reports share one implementation of \"a rate names its\n * denominator\". Re-exported here because the email cards import them from\n * this path, and a rate that renders differently on two surfaces is the thing\n * the shared copy exists to prevent.\n */\nexport {\n Figure,\n percent,\n RateRow,\n Section,\n type MeasuredRate,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\n\nimport { Stack, Typography } from '@mui/material'\nimport type { CampaignMoneyPerMessage } from '../model/campaign-revenue'\nimport { percent as sharedPercent } from '@aglyn/shared-ui-jsx/components/measured-figures.component'\n\n/*\n * Money stays here. It is not a figure primitive: it carries a currency, and\n * this surface deliberately refuses to sum two of them into one number.\n */\nexport function money(cents: number, currency: string): string {\n const amount = cents / 100\n try {\n return new Intl.NumberFormat(undefined, {\n style: 'currency',\n currency: currency.toUpperCase(),\n }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${currency.toUpperCase()}`\n }\n}\n\n/**\n * One money figure, with the population it is averaged over named.\n *\n * The same rule {@link RateRow} enforces for a percentage, applied to an\n * average — they go wrong the same way. \"$0.42 per recipient\" is meaningless\n * without the recipient count, and worse than meaningless when the reader\n * assumes a different one from the writer.\n */\nexport function MoneyPerMessageRow(props: {\n label: string\n figure: CampaignMoneyPerMessage | null\n}) {\n const { label, figure } = props\n return (\n <Stack\n direction=\"row\"\n spacing={2}\n sx={{ justifyContent: 'space-between', alignItems: 'baseline' }}\n >\n <Typography variant=\"body2\">{label}</Typography>\n {figure ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'baseline' }}>\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {money(figure.cents, figure.currency)}\n </Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${money(figure.numeratorCents, figure.currency)} over ${figure.denominator.toLocaleString()} ${figure.denominatorLabel}`}\n </Typography>\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'— not enough recorded to compute'}\n </Typography>\n )}\n </Stack>\n )\n}\nMoneyPerMessageRow.displayName = 'MoneyPerMessageRow'\n\n/**\n * One money total, with what it counts named underneath.\n *\n * {@link Figure}'s contract in currency: `null` draws a dash and never a\n * zero, because \"no attribution has ever been recorded\" and \"this campaign\n * earned nothing\" lead a merchant to opposite conclusions about whether to\n * send another one.\n */\nexport function MoneyFigure(props: {\n label: string\n cents: number | null\n currency: string\n note: string\n}) {\n return (\n <Stack sx={{ minWidth: 140 }}>\n <Typography variant=\"h6\">\n {props.cents === null ? '—' : money(props.cents, props.currency)}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.cents === null ? 'not recorded' : props.note}\n </Typography>\n </Stack>\n )\n}\nMoneyFigure.displayName = 'MoneyFigure'\n"],"names":["Figure","percent","RateRow","Section","Stack","Typography","money","cents","currency","amount","Intl","NumberFormat","undefined","style","toUpperCase","format","toFixed","MoneyPerMessageRow","props","label","figure","direction","spacing","sx","justifyContent","alignItems","variant","fontWeight","color","numeratorCents","denominator","toLocaleString","denominatorLabel","displayName","MoneyFigure","minWidth","note"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GACD,SACEA,MAAM,EACNC,OAAO,EACPC,OAAO,EACPC,OAAO,QAEF,6DAA4D;AAEnE,SAASC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AAIjD;;;CAGC,GACD,OAAO,SAASC,MAAMC,KAAa,EAAEC,QAAgB;IACnD,MAAMC,SAASF,QAAQ;IACvB,IAAI;QACF,OAAO,IAAIG,KAAKC,YAAY,CAACC,WAAW;YACtCC,OAAO;YACPL,UAAUA,SAASM,WAAW;QAChC,GAAGC,MAAM,CAACN;IACZ,EAAE,eAAM;QACN,OAAO,GAAGA,OAAOO,OAAO,CAAC,GAAG,CAAC,EAAER,SAASM,WAAW,IAAI;IACzD;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASG,mBAAmBC,KAGlC;IACC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAE,GAAGF;IAC1B,qBACE,MAACd;QACCiB,WAAU;QACVC,SAAS;QACTC,IAAI;YAAEC,gBAAgB;YAAiBC,YAAY;QAAW;;0BAE9D,KAACpB;gBAAWqB,SAAQ;0BAASP;;YAC5BC,uBACC,MAAChB;gBAAMiB,WAAU;gBAAMC,SAAS;gBAAGC,IAAI;oBAAEE,YAAY;gBAAW;;kCAC9D,KAACpB;wBAAWqB,SAAQ;wBAAQH,IAAI;4BAAEI,YAAY;wBAAO;kCAClDrB,MAAMc,OAAOb,KAAK,EAAEa,OAAOZ,QAAQ;;kCAEtC,KAACH;wBAAWqB,SAAQ;wBAAUE,OAAM;kCACjC,GAAGtB,MAAMc,OAAOS,cAAc,EAAET,OAAOZ,QAAQ,EAAE,MAAM,EAAEY,OAAOU,WAAW,CAACC,cAAc,GAAG,CAAC,EAAEX,OAAOY,gBAAgB,EAAE;;;+BAI9H,KAAC3B;gBAAWqB,SAAQ;gBAAUE,OAAM;0BACjC;;;;AAKX;AACAX,mBAAmBgB,WAAW,GAAG;AAEjC;;;;;;;CAOC,GACD,OAAO,SAASC,YAAYhB,KAK3B;IACC,qBACE,MAACd;QAAMmB,IAAI;YAAEY,UAAU;QAAI;;0BACzB,KAAC9B;gBAAWqB,SAAQ;0BACjBR,MAAMX,KAAK,KAAK,OAAO,MAAMD,MAAMY,MAAMX,KAAK,EAAEW,MAAMV,QAAQ;;0BAEjE,KAACH;gBAAWqB,SAAQ;0BAASR,MAAMC,KAAK;;0BACxC,KAACd;gBAAWqB,SAAQ;gBAAUE,OAAM;0BACjCV,MAAMX,KAAK,KAAK,OAAO,iBAAiBW,MAAMkB,IAAI;;;;AAI3D;AACAF,YAAYD,WAAW,GAAG"}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.
|
|
19
|
+
*
|
|
20
|
+
* ## The two collections, and why there are two
|
|
21
|
+
*
|
|
22
|
+
* `hosts/{hostId}/campaigns/{sendId}` holds a SEND: one subject, one body,
|
|
23
|
+
* one audience, one set of counters. That is what the collection has always
|
|
24
|
+
* held, and it is why the container could not simply be that document grown
|
|
25
|
+
* new fields.
|
|
26
|
+
*
|
|
27
|
+
* **Its ids are load-bearing outside this repo.** Every unsubscribe link that
|
|
28
|
+
* has ever gone out carries `cid={sendId}`, those emails sit in inboxes
|
|
29
|
+
* forever, and the `cid` is inside the link's HMAC — so a send id that stops
|
|
30
|
+
* resolving is an opt-out that stops working, which is a compliance failure
|
|
31
|
+
* rather than a broken page. `/marketing/campaigns/{sendId}` is likewise
|
|
32
|
+
* linkable by design: a merchant pastes it into a message about last week's
|
|
33
|
+
* send.
|
|
34
|
+
*
|
|
35
|
+
* So the send collection is left exactly where it is, under exactly its
|
|
36
|
+
* existing ids, and the container is a new collection above it:
|
|
37
|
+
* `hosts/{hostId}/emailCampaigns/{campaignId}`. A send joins one by carrying
|
|
38
|
+
* {@link CAMPAIGN_SEND_CONTAINER_FIELD}; a send written before containers
|
|
39
|
+
* existed carries nothing, and {@link campaignListRows} presents it as a
|
|
40
|
+
* container of one rather than hiding it.
|
|
41
|
+
*
|
|
42
|
+
* That last property is what makes this migration-free. There is no backfill
|
|
43
|
+
* to run, no window in which a merchant's history is missing, and no id
|
|
44
|
+
* rewritten anywhere.
|
|
45
|
+
*
|
|
46
|
+
* ## Why the arithmetic is here
|
|
47
|
+
*
|
|
48
|
+
* The same reason `campaign-report.ts` gives for the per-send rates: a
|
|
49
|
+
* denominator chosen in JSX is a denominator nobody tests. Aggregating across
|
|
50
|
+
* sends adds one problem the single-send report does not have — some sends
|
|
51
|
+
* recorded a field and others never did — and summing those into one number
|
|
52
|
+
* silently reports a partial total as a complete one. Every aggregate here
|
|
53
|
+
* therefore reports how many sends it could measure.
|
|
54
|
+
*/
|
|
55
|
+
import { type CampaignRate, type CampaignStats } from './campaign-report';
|
|
56
|
+
/**
|
|
57
|
+
* The field on a SEND naming the campaign it belongs to.
|
|
58
|
+
*
|
|
59
|
+
* Not `campaignId`: on a send document that name already means the send's own
|
|
60
|
+
* id — it is what `cid` carries and what the report route addresses — and one
|
|
61
|
+
* word meaning both would be read into the other on the first edit.
|
|
62
|
+
*/
|
|
63
|
+
export declare const CAMPAIGN_SEND_CONTAINER_FIELD = "emailCampaignId";
|
|
64
|
+
/**
|
|
65
|
+
* A campaign: the container, not a message.
|
|
66
|
+
*
|
|
67
|
+
* Stored at `hosts/{hostId}/emailCampaigns/{campaignId}`.
|
|
68
|
+
*/
|
|
69
|
+
export interface EmailCampaign {
|
|
70
|
+
$id: string;
|
|
71
|
+
/** What the merchant called it. */
|
|
72
|
+
name: string;
|
|
73
|
+
/** When the campaign window opens. Null for a campaign with no dates. */
|
|
74
|
+
startAtMs?: number | null;
|
|
75
|
+
/** When it closes. Null for an open-ended campaign. */
|
|
76
|
+
endAtMs?: number | null;
|
|
77
|
+
/** Org email lists this campaign is aimed at, by list id. */
|
|
78
|
+
listIds?: string[];
|
|
79
|
+
/**
|
|
80
|
+
* The stream this campaign's emails open on.
|
|
81
|
+
*
|
|
82
|
+
* A DEFAULT, not a constraint. The topic decides who a send skips, what the
|
|
83
|
+
* preference page linked from the footer highlights, and which stream a
|
|
84
|
+
* resulting opt-out is recorded against — all facts about one MESSAGE, and
|
|
85
|
+
* one campaign may legitimately carry a newsletter and a promotion. So the
|
|
86
|
+
* composer's picker is what the send records; this is what the picker opens
|
|
87
|
+
* on, which is what stops a "Sales" campaign quietly mailing under
|
|
88
|
+
* `marketing`.
|
|
89
|
+
*/
|
|
90
|
+
topicId?: string;
|
|
91
|
+
createdAtMs?: number;
|
|
92
|
+
createdBy?: string;
|
|
93
|
+
deletedAt?: unknown;
|
|
94
|
+
}
|
|
95
|
+
/** One send, as much of it as a list or a rollup needs. */
|
|
96
|
+
export interface CampaignSend {
|
|
97
|
+
$id: string;
|
|
98
|
+
subject?: string;
|
|
99
|
+
/** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */
|
|
100
|
+
audience?: string;
|
|
101
|
+
/** The list this send addressed, when the kind is `'list'`. */
|
|
102
|
+
listId?: string;
|
|
103
|
+
/** The segment this send addressed, when the kind is `'segment'`. */
|
|
104
|
+
segmentId?: string;
|
|
105
|
+
/** Which container it belongs to, absent on a send written before them. */
|
|
106
|
+
emailCampaignId?: string;
|
|
107
|
+
status?: string;
|
|
108
|
+
sentAt?: {
|
|
109
|
+
seconds?: number;
|
|
110
|
+
} | null;
|
|
111
|
+
sendAtMs?: number;
|
|
112
|
+
/**
|
|
113
|
+
* When the record was minted, stamped by every writer that creates one.
|
|
114
|
+
*
|
|
115
|
+
* Absent on a send written before the stamp existed. The lists that draw
|
|
116
|
+
* drafts beside sends order on it through `emailListTimeMs`, which is why
|
|
117
|
+
* it is here rather than only on the loose record shape: a draft carries
|
|
118
|
+
* neither `sentAt` nor `sendAtMs`, so this is the only time it has.
|
|
119
|
+
*/
|
|
120
|
+
createdAtMs?: number;
|
|
121
|
+
stats?: CampaignStats;
|
|
122
|
+
/** How far a send that goes out over several batches has got. */
|
|
123
|
+
resume?: CampaignResume;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The batch state the sender writes on an email that is still going out.
|
|
127
|
+
*
|
|
128
|
+
* Absent on every send that finished in one batch, and on every send that
|
|
129
|
+
* predates batching — which is why {@link campaignSendProgress} treats a
|
|
130
|
+
* missing record as "this is not a batched send" rather than as zero.
|
|
131
|
+
*/
|
|
132
|
+
export interface CampaignResume {
|
|
133
|
+
/** People the email has resolved and not yet addressed. */
|
|
134
|
+
remaining?: number;
|
|
135
|
+
/** Batches it has run. */
|
|
136
|
+
batch?: number;
|
|
137
|
+
/** When the next batch may go, ms. Zero when there is not going to be one. */
|
|
138
|
+
nextAtMs?: number;
|
|
139
|
+
/** Why it stopped short, when it did. */
|
|
140
|
+
stop?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.
|
|
144
|
+
*
|
|
145
|
+
* An email larger than one send may carry is delivered over several batches,
|
|
146
|
+
* and between them it is stored as `scheduled` — the state the processor
|
|
147
|
+
* claims, and the only one that resumes it without a second index and a
|
|
148
|
+
* second query. Read literally, that is a row saying "not sent yet" about an
|
|
149
|
+
* email that has already put five hundred messages in five hundred inboxes.
|
|
150
|
+
*
|
|
151
|
+
* So the stored fields are not the sentence. This is: it takes the status,
|
|
152
|
+
* the delivered count and the batch record, and answers what a person needs
|
|
153
|
+
* to see. Derived at read time and never persisted, exactly as
|
|
154
|
+
* `campaignWindowState` beside it is, because it is a description of stored
|
|
155
|
+
* facts and not a fact of its own.
|
|
156
|
+
*
|
|
157
|
+
* ## The four states, and which stored shape each one is
|
|
158
|
+
*
|
|
159
|
+
* - `pending` — `scheduled`, nothing delivered. A campaign waiting for its
|
|
160
|
+
* time. This is what `scheduled` meant before batching and still does.
|
|
161
|
+
* - `sending` — `scheduled` or `sending` with something delivered and more to
|
|
162
|
+
* come. The state this function exists for.
|
|
163
|
+
* - `sent` — finished, whether in one batch or six.
|
|
164
|
+
* - `stopped` — finished with people it never addressed: canceled mid-flight,
|
|
165
|
+
* failed mid-flight, or stopped by the batch guard. The count is what makes
|
|
166
|
+
* this legible rather than alarming — an email that reached 2,400 of 3,000
|
|
167
|
+
* and stopped is a different conversation from one that reached nobody.
|
|
168
|
+
*/
|
|
169
|
+
export type CampaignSendProgressState = 'pending' | 'sending' | 'sent' | 'stopped';
|
|
170
|
+
export interface CampaignSendProgress {
|
|
171
|
+
state: CampaignSendProgressState;
|
|
172
|
+
/** Messages this email has delivered. */
|
|
173
|
+
reached: number;
|
|
174
|
+
/**
|
|
175
|
+
* The audience it is working through, when one was recorded. Null when the
|
|
176
|
+
* send never recorded an audience size, which is every send that predates
|
|
177
|
+
* the figure — reported as null rather than as `reached` so a surface does
|
|
178
|
+
* not present a floor as a total.
|
|
179
|
+
*/
|
|
180
|
+
audience: number | null;
|
|
181
|
+
/** People it has resolved and not yet addressed. */
|
|
182
|
+
remaining: number;
|
|
183
|
+
/** Batches it has run. Zero for a send that never batched. */
|
|
184
|
+
batch: number;
|
|
185
|
+
/** When the next batch may go, ms. Zero unless {@link state} is `sending`. */
|
|
186
|
+
nextAtMs: number;
|
|
187
|
+
/** One line a surface may show verbatim. */
|
|
188
|
+
label: string;
|
|
189
|
+
}
|
|
190
|
+
export declare function campaignSendProgress(send: CampaignSend | null | undefined): CampaignSendProgress;
|
|
191
|
+
/**
|
|
192
|
+
* What a ROW says about one email, in one word and one line.
|
|
193
|
+
*
|
|
194
|
+
* {@link campaignSendProgress} answers what a send is DOING, and a draft is
|
|
195
|
+
* not doing anything: it has no status the progress states cover, and — since
|
|
196
|
+
* an absent status reads as `sent` and a draft has no counters — asking it
|
|
197
|
+
* about one answers "Sent to 0", which is the worst available sentence about
|
|
198
|
+
* an email nobody has written yet. So the draft is settled here and
|
|
199
|
+
* everything else is deferred, unchanged, to the derivation that owns it.
|
|
200
|
+
*
|
|
201
|
+
* One helper rather than the same two-line branch on four surfaces. The
|
|
202
|
+
* campaigns table, the emails list, an email's own page and a campaign's
|
|
203
|
+
* emails table all draw this, and four copies is how three of them come to
|
|
204
|
+
* say "Scheduled" about a campaign that has delivered five hundred messages.
|
|
205
|
+
*/
|
|
206
|
+
export type CampaignSendDisplayState = 'draft' | CampaignSendProgressState;
|
|
207
|
+
export interface CampaignSendDisplay {
|
|
208
|
+
state: CampaignSendDisplayState;
|
|
209
|
+
/** One line a surface may show verbatim. */
|
|
210
|
+
label: string;
|
|
211
|
+
/** The progress underneath, for a surface that wants the figures. */
|
|
212
|
+
progress: CampaignSendProgress;
|
|
213
|
+
}
|
|
214
|
+
export declare function campaignSendDisplay(send: CampaignSend | null | undefined): CampaignSendDisplay;
|
|
215
|
+
/**
|
|
216
|
+
* Whether this email is between batches, with more of its audience to reach.
|
|
217
|
+
*
|
|
218
|
+
* The one question two controls on an email's page turn on. It is stored as
|
|
219
|
+
* `scheduled` — the state the processor claims — so a surface reading the
|
|
220
|
+
* status alone offers "Send now" on a campaign that is already going out, and
|
|
221
|
+
* `sendNow` re-resolves the WHOLE audience rather than continuing: everyone
|
|
222
|
+
* already reached would receive a second copy under the same `cid`.
|
|
223
|
+
*/
|
|
224
|
+
export declare function campaignSendIsMidFlight(send: CampaignSend | null | undefined): boolean;
|
|
225
|
+
/**
|
|
226
|
+
* The lists ONE SEND addressed — which can be narrower than the lists its
|
|
227
|
+
* campaign is aimed at.
|
|
228
|
+
*
|
|
229
|
+
* A campaign holds the lists a merchant plans to reach; each send inside it
|
|
230
|
+
* picks one audience, and that audience may be a segment or the site's leads
|
|
231
|
+
* rather than any of them. Answering from the send is therefore the only
|
|
232
|
+
* honest answer for a send's own detail page.
|
|
233
|
+
*
|
|
234
|
+
* An array for a document that stores one id, deliberately: the question
|
|
235
|
+
* "which lists did this reach" has a plural answer everywhere it is asked,
|
|
236
|
+
* and a caller that unwraps a single id today is a caller to revisit if a
|
|
237
|
+
* send ever addresses two.
|
|
238
|
+
*/
|
|
239
|
+
export declare function campaignSendListIds(send: CampaignSend): string[];
|
|
240
|
+
/**
|
|
241
|
+
* A number summed across sends, with how much of the campaign it covers.
|
|
242
|
+
*
|
|
243
|
+
* `value` is `null` when NO send recorded the field — which is not zero, for
|
|
244
|
+
* the reason `campaign-report.ts` gives at length: an unrecorded delivery
|
|
245
|
+
* count and a delivery count of zero lead a merchant to opposite conclusions
|
|
246
|
+
* about their sending domain.
|
|
247
|
+
*
|
|
248
|
+
* `recorded` below `sends` means the total is a floor. A campaign whose
|
|
249
|
+
* older sends predate the delivery webhook has a real number that describes
|
|
250
|
+
* part of itself, and saying which part is the difference between a total and
|
|
251
|
+
* a guess.
|
|
252
|
+
*/
|
|
253
|
+
export interface CampaignAggregate {
|
|
254
|
+
value: number | null;
|
|
255
|
+
/** Sends that recorded this field. */
|
|
256
|
+
recorded: number;
|
|
257
|
+
/** Sends in the campaign. */
|
|
258
|
+
sends: number;
|
|
259
|
+
}
|
|
260
|
+
/** Every rolled-up figure for one campaign. */
|
|
261
|
+
export interface CampaignRollup {
|
|
262
|
+
/** Sends that have actually gone out. */
|
|
263
|
+
sends: number;
|
|
264
|
+
/** Sends still waiting for their send time, having delivered nothing. */
|
|
265
|
+
scheduled: number;
|
|
266
|
+
/**
|
|
267
|
+
* Sends part way through an audience larger than one batch.
|
|
268
|
+
*
|
|
269
|
+
* Counted apart from `scheduled` and from `sends`, because it is neither:
|
|
270
|
+
* mail has gone out, and more is going to. Both of those are facts a
|
|
271
|
+
* merchant reading a campaign row needs, and the stored status carries
|
|
272
|
+
* only the first.
|
|
273
|
+
*/
|
|
274
|
+
sending: number;
|
|
275
|
+
/**
|
|
276
|
+
* Emails that have been created and not yet written or sent.
|
|
277
|
+
*
|
|
278
|
+
* Counted apart from `sends` and from `scheduled`, because a draft is
|
|
279
|
+
* neither: it has mailed nobody, and it is not on the clock to.
|
|
280
|
+
*/
|
|
281
|
+
drafts: number;
|
|
282
|
+
addressed: CampaignAggregate;
|
|
283
|
+
sent: CampaignAggregate;
|
|
284
|
+
delivered: CampaignAggregate;
|
|
285
|
+
opens: CampaignAggregate;
|
|
286
|
+
uniqueOpens: CampaignAggregate;
|
|
287
|
+
clicks: CampaignAggregate;
|
|
288
|
+
uniqueClicks: CampaignAggregate;
|
|
289
|
+
bounced: CampaignAggregate;
|
|
290
|
+
complained: CampaignAggregate;
|
|
291
|
+
unsubscribes: CampaignAggregate;
|
|
292
|
+
/** Distinct openers over delivered, across every send that recorded both. */
|
|
293
|
+
openRate: CampaignRate | null;
|
|
294
|
+
/** Distinct clickers over delivered. */
|
|
295
|
+
clickRate: CampaignRate | null;
|
|
296
|
+
/** Unsubscribes over delivered. */
|
|
297
|
+
unsubscribeRate: CampaignRate | null;
|
|
298
|
+
/** The most recent send time in the campaign, for ordering a list. */
|
|
299
|
+
lastSentAtMs: number | null;
|
|
300
|
+
}
|
|
301
|
+
/** When a send happened, in epoch milliseconds, or null. */
|
|
302
|
+
export declare function campaignSendAtMs(send: CampaignSend): number | null;
|
|
303
|
+
/**
|
|
304
|
+
* Rolls a campaign's sends into one set of figures.
|
|
305
|
+
*
|
|
306
|
+
* Rates are taken over the sends that recorded BOTH sides, so a campaign
|
|
307
|
+
* whose first send predates the delivery webhook reports the open rate of the
|
|
308
|
+
* sends that can be measured rather than one deflated by a send with no
|
|
309
|
+
* denominator.
|
|
310
|
+
*/
|
|
311
|
+
export declare function campaignRollup(sends: CampaignSend[]): CampaignRollup;
|
|
312
|
+
/**
|
|
313
|
+
* Where a campaign stands against its own window.
|
|
314
|
+
*
|
|
315
|
+
* Derived at read time and never persisted — the `status` values on a SEND
|
|
316
|
+
* (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a
|
|
317
|
+
* processor branches on, and a display state sharing those spellings would
|
|
318
|
+
* eventually be written back.
|
|
319
|
+
*/
|
|
320
|
+
export type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended';
|
|
321
|
+
export declare function campaignWindowState(campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>, nowMs: number): CampaignWindowState;
|
|
322
|
+
/** One row of the campaigns table. */
|
|
323
|
+
export interface CampaignListRow {
|
|
324
|
+
/** The id the detail route resolves — a container id, or a send id. */
|
|
325
|
+
id: string;
|
|
326
|
+
name: string;
|
|
327
|
+
/**
|
|
328
|
+
* True when this row IS a send with no container.
|
|
329
|
+
*
|
|
330
|
+
* A campaign sent before containers existed is shown as a campaign of one
|
|
331
|
+
* rather than dropped from the list, and the detail route falls back to the
|
|
332
|
+
* send's own report for it. Nothing about that send is rewritten.
|
|
333
|
+
*/
|
|
334
|
+
legacy: boolean;
|
|
335
|
+
startAtMs: number | null;
|
|
336
|
+
endAtMs: number | null;
|
|
337
|
+
listIds: string[];
|
|
338
|
+
sends: CampaignSend[];
|
|
339
|
+
rollup: CampaignRollup;
|
|
340
|
+
windowState: CampaignWindowState;
|
|
341
|
+
/** For ordering: the campaign's start, else its most recent send. */
|
|
342
|
+
atMs: number | null;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The campaigns table's rows: every container, plus every send that belongs
|
|
346
|
+
* to none.
|
|
347
|
+
*
|
|
348
|
+
* The second half is what makes the container additive. A merchant's history
|
|
349
|
+
* is the sends they already have; a list that showed only containers would
|
|
350
|
+
* read as an empty product on the day this shipped, and a backfill that
|
|
351
|
+
* adopted each old send into a container of one would rewrite documents whose
|
|
352
|
+
* ids are cited by mail already delivered. Adopting them AT READ TIME costs a
|
|
353
|
+
* pass over a list already in memory and rewrites nothing.
|
|
354
|
+
*
|
|
355
|
+
* Newest first, on the start date where there is one and the last send
|
|
356
|
+
* otherwise. Rows with no date at all sort last: they are campaigns nobody
|
|
357
|
+
* has scheduled or sent, and dating them from nothing would be an invention.
|
|
358
|
+
*/
|
|
359
|
+
export declare function campaignListRows(campaigns: EmailCampaign[], sends: CampaignSend[], nowMs: number): CampaignListRow[];
|